@runfusion/fusion 0.22.0 → 0.24.0

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 (206) hide show
  1. package/dist/bin.js +30071 -20735
  2. package/dist/client/assets/AgentDetailView-BwJaLqZh.css +1 -0
  3. package/dist/client/assets/AgentDetailView-gy_5SUj2.js +18 -0
  4. package/dist/client/assets/AgentsView-BkB9FiMT.js +29 -0
  5. package/dist/client/assets/AgentsView-CV3vm7Qk.css +1 -0
  6. package/dist/client/assets/ChatView-B_-B8fqu.js +1 -0
  7. package/dist/client/assets/ChatView-DwJAd5G1.css +1 -0
  8. package/dist/client/assets/{DevServerView-l8RCyL2k.js → DevServerView-BkvtjZBa.js} +1 -1
  9. package/dist/client/assets/{DirectoryPicker-CS1dwqcC.js → DirectoryPicker-BK-KbnhP.js} +1 -1
  10. package/dist/client/assets/{DocumentsView-DmthQWDZ.js → DocumentsView-BEg1CQAk.js} +1 -1
  11. package/dist/client/assets/{DocumentsView-BrhyOdeE.css → DocumentsView-gv4zG3aT.css} +1 -1
  12. package/dist/client/assets/EvalsView-Berf9bQm.js +1 -0
  13. package/dist/client/assets/EvalsView-CUNJ1TLc.css +1 -0
  14. package/dist/client/assets/{agentSkills-DDHJnrkn.css → ExperimentalAgentOnboardingModal-B-APN_lM.css} +1 -1
  15. package/dist/client/assets/ExperimentalAgentOnboardingModal-jcInE50G.js +499 -0
  16. package/dist/client/assets/InsightsView-B0J4mhzV.css +1 -0
  17. package/dist/client/assets/InsightsView-BX5bSF1J.js +11 -0
  18. package/dist/client/assets/{MemoryView-CPwlKnUI.js → MemoryView-CKElJY_3.js} +2 -2
  19. package/dist/client/assets/NodesView-DLUOBLf6.js +14 -0
  20. package/dist/client/assets/NodesView-DT4pXowv.css +1 -0
  21. package/dist/client/assets/{PiExtensionsManager-j8rPXqmB.js → PiExtensionsManager-COlJf0Kx.js} +2 -2
  22. package/dist/client/assets/PluginManager-CfW55BF4.js +1 -0
  23. package/dist/client/assets/PluginManager-DtRQXia5.css +1 -0
  24. package/dist/client/assets/{ResearchView-D9DNJYDq.js → ResearchView-B256Lr8I.js} +1 -1
  25. package/dist/client/assets/SettingsModal-BeA_nQtW.js +31 -0
  26. package/dist/client/assets/SettingsModal-DzsLquBu.css +1 -0
  27. package/dist/client/assets/{SettingsModal-fxvTFLtR.js → SettingsModal-yRqM4DV8.js} +1 -1
  28. package/dist/client/assets/SetupWizardModal-uUZk3TKT.js +1 -0
  29. package/dist/client/assets/{SkillsView-Ddf0YL8z.js → SkillsView-CP8JX0P_.js} +1 -1
  30. package/dist/client/assets/TodoView-Cx9cVhq7.css +1 -0
  31. package/dist/client/assets/TodoView-DCRIkDZ-.js +6 -0
  32. package/dist/client/assets/createLucideIcon-BazL2hk5.js +21 -0
  33. package/dist/client/assets/dashboard-view-BkTMSZYn.css +1 -0
  34. package/dist/client/assets/dashboard-view-CyWN-d02.js +63 -0
  35. package/dist/client/assets/dashboard-view-lR7YYmSC.js +21 -0
  36. package/dist/client/assets/{folder-open-BiJpmnaT.js → folder-open-DHjELt8-.js} +1 -1
  37. package/dist/client/assets/index-CQyVRLOb.js +692 -0
  38. package/dist/client/assets/index-CxA2Nn0_.css +1 -0
  39. package/dist/client/assets/projectDetection-G3XuxD2X.js +1 -0
  40. package/dist/client/assets/{star-BwRZmiuZ.js → star-DYesq1AV.js} +1 -1
  41. package/dist/client/assets/{upload-D4NwZhPp.js → upload-DTWF3Db5.js} +1 -1
  42. package/dist/client/assets/{users-DNISDtI1.js → users--syrel4l.js} +1 -1
  43. package/dist/client/index.html +12 -20
  44. package/dist/client/theme-data.css +106 -0
  45. package/dist/client/version.json +1 -1
  46. package/dist/droid-cli/package.json +1 -1
  47. package/dist/extension.js +17072 -9627
  48. package/dist/pi-claude-cli/package.json +1 -1
  49. package/dist/plugins/fusion-plugin-cursor-runtime/bundled.js +218 -0
  50. package/dist/plugins/fusion-plugin-cursor-runtime/manifest.json +6 -0
  51. package/dist/plugins/fusion-plugin-cursor-runtime/package.json +11 -0
  52. package/dist/plugins/fusion-plugin-dependency-graph/manifest.json +1 -1
  53. package/dist/plugins/fusion-plugin-dependency-graph/package.json +6 -4
  54. package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.css +58 -0
  55. package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.tsx +301 -0
  56. package/dist/plugins/fusion-plugin-dependency-graph/src/GraphHighlight.css +27 -0
  57. package/dist/plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.css +157 -0
  58. package/dist/plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx +126 -0
  59. package/dist/plugins/fusion-plugin-dependency-graph/src/GraphToolbar.css +35 -0
  60. package/dist/plugins/fusion-plugin-dependency-graph/src/GraphToolbar.tsx +36 -0
  61. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraph.highlighting.test.tsx +112 -0
  62. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraph.persistence.test.tsx +115 -0
  63. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraph.test.tsx +128 -0
  64. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphTaskNode.drag.test.tsx +82 -0
  65. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphTaskNode.test.tsx +307 -0
  66. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphToolbar.test.tsx +60 -0
  67. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/edges.test.tsx +75 -0
  68. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/filtering.test.tsx +62 -0
  69. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/filters.test.ts +78 -0
  70. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/graphPositionStorage.test.ts +95 -0
  71. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/host-integration.test.ts +74 -0
  72. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/index.test.ts +58 -0
  73. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/interactions.test.tsx +121 -0
  74. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/layout.test.ts +70 -0
  75. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/persistence.test.tsx +89 -0
  76. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/useGraphData.test.ts +86 -0
  77. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/useGraphInteraction.test.ts +167 -0
  78. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/useGraphPositions.test.ts +66 -0
  79. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/useNodeDrag.test.ts +81 -0
  80. package/dist/plugins/fusion-plugin-dependency-graph/src/dashboard-interop.d.ts +35 -0
  81. package/dist/plugins/fusion-plugin-dependency-graph/src/dashboard-view.tsx +19 -0
  82. package/dist/plugins/fusion-plugin-dependency-graph/src/edges.tsx +70 -0
  83. package/dist/plugins/fusion-plugin-dependency-graph/src/filters.ts +8 -0
  84. package/dist/plugins/fusion-plugin-dependency-graph/src/hooks/__tests__/useDependencyChain.test.ts +53 -0
  85. package/dist/plugins/fusion-plugin-dependency-graph/src/hooks/useDependencyChain.ts +60 -0
  86. package/dist/plugins/fusion-plugin-dependency-graph/src/hooks/useGraphPositions.ts +45 -0
  87. package/dist/plugins/fusion-plugin-dependency-graph/src/hooks/useNodeDrag.ts +114 -0
  88. package/dist/plugins/fusion-plugin-dependency-graph/src/index.ts +1 -2
  89. package/dist/plugins/fusion-plugin-dependency-graph/src/layout.ts +91 -0
  90. package/dist/plugins/fusion-plugin-dependency-graph/src/styles/drag.css +15 -0
  91. package/dist/plugins/fusion-plugin-dependency-graph/src/types.ts +21 -0
  92. package/dist/plugins/fusion-plugin-dependency-graph/src/useGraphData.ts +17 -0
  93. package/dist/plugins/fusion-plugin-dependency-graph/src/useGraphInteraction.ts +292 -0
  94. package/dist/plugins/fusion-plugin-dependency-graph/src/utils/graphPositionStorage.ts +65 -0
  95. package/dist/plugins/fusion-plugin-droid-runtime/bundled.js +136680 -0
  96. package/dist/plugins/fusion-plugin-droid-runtime/manifest.json +13 -0
  97. package/dist/plugins/fusion-plugin-droid-runtime/mcp-schema-server.cjs +49 -0
  98. package/dist/plugins/fusion-plugin-droid-runtime/package.json +11 -0
  99. package/dist/plugins/fusion-plugin-hermes-runtime/bundled.js +176 -7
  100. package/dist/plugins/fusion-plugin-hermes-runtime/package.json +1 -1
  101. package/dist/plugins/fusion-plugin-openclaw-runtime/bundled.js +93 -6
  102. package/dist/plugins/fusion-plugin-openclaw-runtime/mcp-schema-server.cjs +59 -0
  103. package/dist/plugins/fusion-plugin-openclaw-runtime/package.json +1 -1
  104. package/dist/plugins/fusion-plugin-paperclip-runtime/package.json +1 -1
  105. package/dist/plugins/fusion-plugin-reports/manifest.json +33 -0
  106. package/dist/plugins/fusion-plugin-reports/package.json +26 -0
  107. package/dist/plugins/fusion-plugin-reports/src/__tests__/manifest.test.ts +51 -0
  108. package/dist/plugins/fusion-plugin-reports/src/__tests__/review-panel.test.ts +166 -0
  109. package/dist/plugins/fusion-plugin-reports/src/__tests__/settings.test.ts +157 -0
  110. package/dist/plugins/fusion-plugin-reports/src/index.ts +41 -0
  111. package/dist/plugins/fusion-plugin-reports/src/review-panel.ts +294 -0
  112. package/dist/plugins/fusion-plugin-reports/src/review-types.ts +75 -0
  113. package/dist/plugins/fusion-plugin-reports/src/settings.ts +105 -0
  114. package/dist/plugins/fusion-plugin-roadmap/manifest.json +16 -0
  115. package/dist/plugins/fusion-plugin-roadmap/package.json +48 -0
  116. package/dist/plugins/fusion-plugin-roadmap/src/__tests__/api-client.test.ts +101 -0
  117. package/dist/plugins/fusion-plugin-roadmap/src/__tests__/index.test.ts +92 -0
  118. package/dist/plugins/fusion-plugin-roadmap/src/__tests__/roadmap-routes.test.ts +48 -0
  119. package/dist/plugins/fusion-plugin-roadmap/src/__tests__/roadmap-suggestions.test.ts +31 -0
  120. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.css +1299 -0
  121. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.tsx +2559 -0
  122. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/__tests__/RoadmapsView.test.tsx +1144 -0
  123. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/__tests__/useRoadmaps.test.ts +1756 -0
  124. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/api.ts +70 -0
  125. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/test-setup.ts +7 -0
  126. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/types.ts +1 -0
  127. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/useConfirm.ts +8 -0
  128. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/useRoadmaps.ts +1188 -0
  129. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/useViewportMode.ts +20 -0
  130. package/dist/plugins/fusion-plugin-roadmap/src/dashboard-view.tsx +6 -0
  131. package/dist/plugins/fusion-plugin-roadmap/src/index.ts +74 -0
  132. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-routes.ts +1 -0
  133. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-schema.ts +41 -0
  134. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-suggestions.d.ts +15 -0
  135. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-suggestions.ts +15 -0
  136. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts +283 -0
  137. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts.map +1 -0
  138. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.js +21 -0
  139. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.js.map +1 -0
  140. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.ts +310 -0
  141. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts +5 -0
  142. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts.map +1 -0
  143. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js +361 -0
  144. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js.map +1 -0
  145. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.ts +408 -0
  146. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts +68 -0
  147. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts.map +1 -0
  148. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js +300 -0
  149. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js.map +1 -0
  150. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.ts +381 -0
  151. package/dist/plugins/fusion-plugin-roadmap/src/server/index.d.ts +3 -0
  152. package/dist/plugins/fusion-plugin-roadmap/src/server/index.ts +1 -0
  153. package/dist/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-handoff.test.ts +445 -0
  154. package/dist/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-ordering.test.ts +334 -0
  155. package/dist/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts +1318 -0
  156. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-handoff.ts +163 -0
  157. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts +37 -0
  158. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts.map +1 -0
  159. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js +188 -0
  160. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js.map +1 -0
  161. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.ts +311 -0
  162. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts +299 -0
  163. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts.map +1 -0
  164. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.js +765 -0
  165. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.js.map +1 -0
  166. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.ts +1001 -0
  167. package/dist/plugins/fusion-plugin-whatsapp-chat/manifest.json +8 -0
  168. package/dist/plugins/fusion-plugin-whatsapp-chat/package.json +34 -0
  169. package/dist/plugins/fusion-plugin-whatsapp-chat/src/__tests__/auth-state.test.ts +99 -0
  170. package/dist/plugins/fusion-plugin-whatsapp-chat/src/__tests__/connection.test.ts +145 -0
  171. package/dist/plugins/fusion-plugin-whatsapp-chat/src/__tests__/index.test.ts +216 -0
  172. package/dist/plugins/fusion-plugin-whatsapp-chat/src/__tests__/reply.test.ts +52 -0
  173. package/dist/plugins/fusion-plugin-whatsapp-chat/src/auth-state.ts +89 -0
  174. package/dist/plugins/fusion-plugin-whatsapp-chat/src/connection.ts +253 -0
  175. package/dist/plugins/fusion-plugin-whatsapp-chat/src/index.ts +262 -0
  176. package/dist/plugins/fusion-plugin-whatsapp-chat/src/qrcode.d.ts +1 -0
  177. package/dist/plugins/fusion-plugin-whatsapp-chat/src/reply.ts +37 -0
  178. package/package.json +2 -2
  179. package/skill/fusion/SKILL.md +2 -2
  180. package/skill/fusion/references/engine-tools.md +8 -2
  181. package/skill/fusion/references/extension-tools.md +39 -0
  182. package/skill/fusion/references/fusion-capabilities.md +3 -0
  183. package/dist/client/assets/AgentDetailView-BKKpbp1S.js +0 -18
  184. package/dist/client/assets/AgentDetailView-CeO_1MK7.css +0 -1
  185. package/dist/client/assets/AgentsView-BRXFmrcJ.js +0 -527
  186. package/dist/client/assets/AgentsView-Bs03ptrd.css +0 -1
  187. package/dist/client/assets/ChatView-D7L2e_qu.js +0 -1
  188. package/dist/client/assets/InsightsView-AWo5o_81.css +0 -1
  189. package/dist/client/assets/InsightsView-DvXpMKmH.js +0 -11
  190. package/dist/client/assets/NodesView-BLlfUfsy.js +0 -14
  191. package/dist/client/assets/NodesView-fXqDk9ur.css +0 -1
  192. package/dist/client/assets/PluginManager-DA_T0GHn.css +0 -1
  193. package/dist/client/assets/PluginManager-pW6RMz5z.js +0 -1
  194. package/dist/client/assets/RoadmapsView-Djc_X35v.js +0 -6
  195. package/dist/client/assets/SettingsModal-BWe0KrGY.css +0 -1
  196. package/dist/client/assets/SettingsModal-WGCF_pk8.js +0 -31
  197. package/dist/client/assets/SetupWizardModal-tG_MF_nA.js +0 -1
  198. package/dist/client/assets/agentSkills-EwIwBlG8.js +0 -1
  199. package/dist/client/assets/index-D6ebxTPF.css +0 -1
  200. package/dist/client/assets/index-DYDLmOcK.js +0 -694
  201. package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraphView.css +0 -132
  202. package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraphView.tsx +0 -428
  203. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraphView.test.tsx +0 -261
  204. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/storage.test.ts +0 -31
  205. package/dist/plugins/fusion-plugin-dependency-graph/src/storage.ts +0 -23
  206. /package/dist/client/assets/{RoadmapsView-DdGlfuu-.css → dashboard-view-DdGlfuu-.css} +0 -0
@@ -0,0 +1,1188 @@
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import type {
3
+ Roadmap,
4
+ RoadmapMilestone,
5
+ RoadmapFeature,
6
+ RoadmapCreateInput,
7
+ RoadmapUpdateInput,
8
+ RoadmapMilestoneCreateInput,
9
+ RoadmapMilestoneUpdateInput,
10
+ RoadmapFeatureCreateInput,
11
+ RoadmapFeatureUpdateInput,
12
+ RoadmapWithHierarchy,
13
+ RoadmapMissionPlanningHandoff,
14
+ RoadmapFeatureTaskPlanningHandoff,
15
+ } from "../roadmap-types.js";
16
+ import * as api from "./api.js";
17
+
18
+ /**
19
+ * A suggested milestone from AI generation with a stable local draft ID.
20
+ * Draft IDs enable stable identity when drafts are reordered or edited.
21
+ * Drafts are ephemeral and NOT persisted until explicit acceptance.
22
+ */
23
+ export interface MilestoneSuggestion {
24
+ /** Stable local draft ID for UI binding and identity */
25
+ id: string;
26
+ title: string;
27
+ description?: string;
28
+ }
29
+
30
+ /**
31
+ * A suggested feature from AI generation with a stable local draft ID.
32
+ * Draft IDs enable stable identity when drafts are reordered or edited.
33
+ * Drafts are ephemeral and NOT persisted until explicit acceptance.
34
+ */
35
+ export interface FeatureSuggestion {
36
+ /** Stable local draft ID for UI binding and identity */
37
+ id: string;
38
+ title: string;
39
+ description?: string;
40
+ }
41
+
42
+ /** Patch type for updating a suggestion draft */
43
+ export type SuggestionDraftPatch = {
44
+ title?: string;
45
+ description?: string;
46
+ };
47
+
48
+ export interface UseRoadmapsOptions {
49
+ /** When provided, fetches roadmaps for this project */
50
+ projectId?: string;
51
+ }
52
+
53
+ export interface UseRoadmapsResult {
54
+ /** All roadmaps for the current project */
55
+ roadmaps: Roadmap[];
56
+ /** Currently selected roadmap ID */
57
+ selectedRoadmapId: string | null;
58
+ /** Selected roadmap with full hierarchy (milestones and features) */
59
+ selectedRoadmap: RoadmapWithHierarchy | null;
60
+ /** Milestones for the selected roadmap */
61
+ milestones: RoadmapMilestone[];
62
+ /** Features by milestone ID */
63
+ featuresByMilestoneId: Record<string, RoadmapFeature[]>;
64
+ /** Loading state */
65
+ loading: boolean;
66
+ /** Error state */
67
+ error: Error | null;
68
+
69
+ // Roadmap CRUD callbacks
70
+ /** Create a new roadmap */
71
+ createRoadmap: (input: RoadmapCreateInput, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
72
+ /** Update a roadmap */
73
+ updateRoadmap: (roadmapId: string, updates: RoadmapUpdateInput, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
74
+ /** Delete a roadmap */
75
+ deleteRoadmap: (roadmapId: string, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
76
+ /** Select a roadmap to view its details */
77
+ selectRoadmap: (roadmapId: string | null) => void;
78
+
79
+ // Milestone CRUD callbacks
80
+ /** Create a milestone in the selected roadmap */
81
+ createMilestone: (input: RoadmapMilestoneCreateInput, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
82
+ /** Update a milestone */
83
+ updateMilestone: (milestoneId: string, updates: RoadmapMilestoneUpdateInput, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
84
+ /** Delete a milestone */
85
+ deleteMilestone: (milestoneId: string, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
86
+
87
+ // Milestone ordering callbacks
88
+ /** Reorder milestones within a roadmap */
89
+ reorderMilestones: (roadmapId: string, orderedMilestoneIds: string[], opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
90
+
91
+ // Feature CRUD callbacks
92
+ /** Create a feature in a milestone */
93
+ createFeature: (milestoneId: string, input: RoadmapFeatureCreateInput, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
94
+ /** Update a feature */
95
+ updateFeature: (featureId: string, updates: RoadmapFeatureUpdateInput, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
96
+ /** Delete a feature */
97
+ deleteFeature: (featureId: string, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
98
+
99
+ // Feature ordering callbacks
100
+ /** Reorder features within a milestone */
101
+ reorderFeatures: (milestoneId: string, orderedFeatureIds: string[], opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
102
+ /** Move a feature to a different milestone or position */
103
+ moveFeature: (featureId: string, targetMilestoneId: string, targetIndex: number, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
104
+
105
+ // Milestone suggestion callbacks
106
+ /** Current pending milestone suggestions (ephemeral, in-memory only) */
107
+ milestoneSuggestions: MilestoneSuggestion[];
108
+ /** Whether suggestions are currently being generated */
109
+ isGeneratingSuggestions: boolean;
110
+ /** Generate milestone suggestions from a goal prompt */
111
+ generateMilestoneSuggestions: (goalPrompt: string, count?: number, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
112
+ /** Update a milestone suggestion draft before acceptance */
113
+ updateMilestoneSuggestionDraft: (draftId: string, patch: SuggestionDraftPatch) => void;
114
+ /** Accept a single milestone suggestion and create it as a milestone */
115
+ acceptMilestoneSuggestion: (draftId: string, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
116
+ /** Accept all milestone suggestions and create them as milestones (sequentially, in draft order) */
117
+ acceptAllMilestoneSuggestions: (opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
118
+ /** Clear all pending milestone suggestions */
119
+ clearMilestoneSuggestions: () => void;
120
+
121
+ // Feature suggestion callbacks (ephemeral, scoped by milestone)
122
+ /** Pending feature suggestions by milestone ID (ephemeral, in-memory only) */
123
+ featureSuggestionsByMilestoneId: Record<string, FeatureSuggestion[]>;
124
+ /** Whether feature suggestions are being generated for a specific milestone */
125
+ isGeneratingFeatureSuggestions: (milestoneId: string) => boolean;
126
+ /** Generate feature suggestions for a specific milestone */
127
+ generateFeatureSuggestions: (milestoneId: string, input?: { prompt?: string; count?: number }, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
128
+ /** Update a feature suggestion draft before acceptance */
129
+ updateFeatureSuggestionDraft: (milestoneId: string, draftId: string, patch: SuggestionDraftPatch) => void;
130
+ /** Accept a single feature suggestion and create it as a feature */
131
+ acceptFeatureSuggestion: (milestoneId: string, draftId: string, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
132
+ /** Accept all feature suggestions for a milestone (sequentially, in draft order) */
133
+ acceptAllFeatureSuggestions: (milestoneId: string, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
134
+ /** Clear pending feature suggestions for a specific milestone */
135
+ clearFeatureSuggestions: (milestoneId: string) => void;
136
+
137
+ // Handoff / Export callbacks
138
+ /** Current handoff payload (mission + feature handoffs) */
139
+ handoffPayload: { mission: RoadmapMissionPlanningHandoff; features: RoadmapFeatureTaskPlanningHandoff[] } | null;
140
+ /** Whether handoff is currently being fetched */
141
+ isFetchingHandoff: boolean;
142
+ /** Error from the last handoff fetch attempt */
143
+ handoffError: Error | null;
144
+ /** Fetch handoff payload for a roadmap */
145
+ fetchHandoff: (roadmapId: string, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
146
+ /** Clear the current handoff payload */
147
+ clearHandoff: () => void;
148
+
149
+ /** Refresh all roadmaps */
150
+ refresh: () => Promise<void>;
151
+ }
152
+
153
+ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
154
+ const projectId = options?.projectId;
155
+ const [roadmaps, setRoadmaps] = useState<Roadmap[]>([]);
156
+ const [selectedRoadmapId, setSelectedRoadmapId] = useState<string | null>(null);
157
+ const [selectedRoadmap, setSelectedRoadmap] = useState<RoadmapWithHierarchy | null>(null);
158
+ const [milestones, setMilestones] = useState<RoadmapMilestone[]>([]);
159
+ const [featuresByMilestoneId, setFeaturesByMilestoneId] = useState<Record<string, RoadmapFeature[]>>({});
160
+ const [loading, setLoading] = useState(false);
161
+ const [error, setError] = useState<Error | null>(null);
162
+
163
+ // Handoff state
164
+ const [handoffPayload, setHandoffPayload] = useState<{ mission: RoadmapMissionPlanningHandoff; features: RoadmapFeatureTaskPlanningHandoff[] } | null>(null);
165
+ const [isFetchingHandoff, setIsFetchingHandoff] = useState(false);
166
+ const [handoffError, setHandoffError] = useState<Error | null>(null);
167
+
168
+ // Ephemeral milestone suggestion state (in-memory only, not persisted)
169
+ const [milestoneSuggestions, setMilestoneSuggestions] = useState<MilestoneSuggestion[]>([]);
170
+ const [isGeneratingSuggestions, setIsGeneratingSuggestions] = useState(false);
171
+
172
+ // Ephemeral feature suggestion state keyed by milestone ID (in-memory only, not persisted)
173
+ const [featureSuggestionsByMilestoneId, setFeatureSuggestionsByMilestoneId] = useState<Record<string, FeatureSuggestion[]>>({});
174
+ const [generatingFeatureSuggestions, setGeneratingFeatureSuggestions] = useState<Record<string, boolean>>({});
175
+
176
+ // Refs for feature suggestion state
177
+ const featureSuggestionsByMilestoneIdRef = useRef(featureSuggestionsByMilestoneId);
178
+ const generatingFeatureSuggestionsRef = useRef(generatingFeatureSuggestions);
179
+ const milestoneSuggestionsRef = useRef(milestoneSuggestions);
180
+
181
+ featureSuggestionsByMilestoneIdRef.current = featureSuggestionsByMilestoneId;
182
+ generatingFeatureSuggestionsRef.current = generatingFeatureSuggestions;
183
+ milestoneSuggestionsRef.current = milestoneSuggestions;
184
+
185
+ // Track previous projectId to detect changes
186
+ const previousProjectIdRef = useRef<string | undefined>(projectId);
187
+ // Project context version for stale-response protection
188
+ const projectContextVersionRef = useRef(0);
189
+ // Handoff fetch version for stale-response discard
190
+ const handoffFetchVersionRef = useRef(0);
191
+ // Refs to access latest state in callbacks
192
+ const roadmapsRef = useRef(roadmaps);
193
+ const selectedRoadmapIdRef = useRef(selectedRoadmapId);
194
+ const milestonesRef = useRef(milestones);
195
+ const featuresByMilestoneIdRef = useRef(featuresByMilestoneId);
196
+ const projectIdRef = useRef(projectId);
197
+ const handoffPayloadRef = useRef(handoffPayload);
198
+
199
+ roadmapsRef.current = roadmaps;
200
+ selectedRoadmapIdRef.current = selectedRoadmapId;
201
+ milestonesRef.current = milestones;
202
+ featuresByMilestoneIdRef.current = featuresByMilestoneId;
203
+ projectIdRef.current = projectId;
204
+ handoffPayloadRef.current = handoffPayload;
205
+
206
+ // Clear selection and suggestions when project changes
207
+ useEffect(() => {
208
+ if (previousProjectIdRef.current !== projectId) {
209
+ previousProjectIdRef.current = projectId;
210
+ projectContextVersionRef.current++;
211
+ setSelectedRoadmapId(null);
212
+ setSelectedRoadmap(null);
213
+ setMilestones([]);
214
+ setFeaturesByMilestoneId({});
215
+ // Clear handoff state
216
+ setHandoffPayload(null);
217
+ setHandoffError(null);
218
+ // Clear ephemeral suggestion state
219
+ setMilestoneSuggestions([]);
220
+ setIsGeneratingSuggestions(false);
221
+ setFeatureSuggestionsByMilestoneId({});
222
+ setGeneratingFeatureSuggestions({});
223
+ }
224
+ }, [projectId]);
225
+
226
+ // Fetch roadmaps on mount and when projectId changes
227
+ const fetchRoadmaps = useCallback(async () => {
228
+ setLoading(true);
229
+ setError(null);
230
+ try {
231
+ const fetchedRoadmaps = await api.fetchRoadmaps(projectId);
232
+ setRoadmaps(fetchedRoadmaps);
233
+ } catch (err) {
234
+ setError(err instanceof Error ? err : new Error("Failed to fetch roadmaps"));
235
+ } finally {
236
+ setLoading(false);
237
+ }
238
+ }, [projectId]);
239
+
240
+ // Fetch selected roadmap with full hierarchy
241
+ const fetchSelectedRoadmap = useCallback(async (roadmapId: string) => {
242
+ try {
243
+ const roadmap = await api.fetchRoadmap(roadmapId, projectId);
244
+ setSelectedRoadmap(roadmap);
245
+ setMilestones(roadmap.milestones || []);
246
+
247
+ // Build features by milestone ID
248
+ const featuresMap: Record<string, RoadmapFeature[]> = {};
249
+ for (const milestone of roadmap.milestones || []) {
250
+ featuresMap[milestone.id] = milestone.features || [];
251
+ }
252
+ setFeaturesByMilestoneId(featuresMap);
253
+ } catch (err) {
254
+ setError(err instanceof Error ? err : new Error("Failed to fetch roadmap"));
255
+ }
256
+ }, [projectId]);
257
+
258
+ // Initial fetch
259
+ useEffect(() => {
260
+ void fetchRoadmaps();
261
+ }, [fetchRoadmaps]);
262
+
263
+ // Fetch selected roadmap when selection changes
264
+ useEffect(() => {
265
+ if (selectedRoadmapId) {
266
+ void fetchSelectedRoadmap(selectedRoadmapId);
267
+ } else {
268
+ setSelectedRoadmap(null);
269
+ setMilestones([]);
270
+ setFeaturesByMilestoneId({});
271
+ }
272
+ }, [selectedRoadmapId, fetchSelectedRoadmap]);
273
+
274
+ // Roadmap CRUD
275
+ const createRoadmap = useCallback(async (
276
+ input: RoadmapCreateInput,
277
+ opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
278
+ ) => {
279
+ try {
280
+ const newRoadmap = await api.createRoadmap(input, projectIdRef.current);
281
+ setRoadmaps((prev) => [...prev, newRoadmap]);
282
+ opts?.onSuccess?.();
283
+ } catch (err) {
284
+ const error = err instanceof Error ? err : new Error("Failed to create roadmap");
285
+ opts?.onError?.(error);
286
+ throw error;
287
+ }
288
+ }, []); // No dependencies needed - uses refs
289
+
290
+ const updateRoadmap = useCallback(async (
291
+ roadmapId: string,
292
+ updates: RoadmapUpdateInput,
293
+ opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
294
+ ) => {
295
+ try {
296
+ const updated = await api.updateRoadmap(roadmapId, updates, projectIdRef.current);
297
+ setRoadmaps((prev) => prev.map((r) => (r.id === roadmapId ? updated : r)));
298
+ if (selectedRoadmapIdRef.current === roadmapId) {
299
+ setSelectedRoadmap((prev) => prev ? { ...prev, ...updated } : null);
300
+ }
301
+ opts?.onSuccess?.();
302
+ } catch (err) {
303
+ const error = err instanceof Error ? err : new Error("Failed to update roadmap");
304
+ opts?.onError?.(error);
305
+ throw error;
306
+ }
307
+ }, []); // No dependencies needed - uses refs
308
+
309
+ const deleteRoadmap = useCallback(async (
310
+ roadmapId: string,
311
+ opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
312
+ ) => {
313
+ try {
314
+ await api.deleteRoadmap(roadmapId, projectIdRef.current);
315
+ setRoadmaps((prev) => prev.filter((r) => r.id !== roadmapId));
316
+ if (selectedRoadmapIdRef.current === roadmapId) {
317
+ setSelectedRoadmapId(null);
318
+ setSelectedRoadmap(null);
319
+ setMilestones([]);
320
+ setFeaturesByMilestoneId({});
321
+ }
322
+ opts?.onSuccess?.();
323
+ } catch (err) {
324
+ const error = err instanceof Error ? err : new Error("Failed to delete roadmap");
325
+ opts?.onError?.(error);
326
+ throw error;
327
+ }
328
+ }, []); // No dependencies needed - uses refs
329
+
330
+ const selectRoadmap = useCallback((roadmapId: string | null) => {
331
+ setSelectedRoadmapId(roadmapId);
332
+ }, []);
333
+
334
+ // Milestone CRUD
335
+ const createMilestone = useCallback(async (
336
+ input: RoadmapMilestoneCreateInput,
337
+ opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
338
+ ) => {
339
+ const currentRoadmapId = selectedRoadmapIdRef.current;
340
+ if (!currentRoadmapId) {
341
+ const error = new Error("No roadmap selected");
342
+ opts?.onError?.(error);
343
+ throw error;
344
+ }
345
+ try {
346
+ const newMilestone = await api.createRoadmapMilestone(currentRoadmapId, input, projectIdRef.current);
347
+ setMilestones((prev) => [...prev, newMilestone]);
348
+ setFeaturesByMilestoneId((prev) => ({ ...prev, [newMilestone.id]: [] }));
349
+ // Refresh the full roadmap to get updated hierarchy
350
+ if (selectedRoadmapIdRef.current) {
351
+ void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
352
+ }
353
+ opts?.onSuccess?.();
354
+ } catch (err) {
355
+ const error = err instanceof Error ? err : new Error("Failed to create milestone");
356
+ opts?.onError?.(error);
357
+ throw error;
358
+ }
359
+ }, [fetchSelectedRoadmap]);
360
+
361
+ const updateMilestone = useCallback(async (
362
+ milestoneId: string,
363
+ updates: RoadmapMilestoneUpdateInput,
364
+ opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
365
+ ) => {
366
+ try {
367
+ const updated = await api.updateRoadmapMilestone(milestoneId, updates, projectIdRef.current);
368
+ setMilestones((prev) => prev.map((m) => (m.id === milestoneId ? updated : m)));
369
+ if (selectedRoadmapIdRef.current) {
370
+ void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
371
+ }
372
+ opts?.onSuccess?.();
373
+ } catch (err) {
374
+ const error = err instanceof Error ? err : new Error("Failed to update milestone");
375
+ opts?.onError?.(error);
376
+ throw error;
377
+ }
378
+ }, [fetchSelectedRoadmap]); // Uses refs internally
379
+
380
+ const deleteMilestone = useCallback(async (
381
+ milestoneId: string,
382
+ opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
383
+ ) => {
384
+ try {
385
+ await api.deleteRoadmapMilestone(milestoneId, projectIdRef.current);
386
+ setMilestones((prev) => prev.filter((m) => m.id !== milestoneId));
387
+ setFeaturesByMilestoneId((prev) => {
388
+ const updated = { ...prev };
389
+ delete updated[milestoneId];
390
+ return updated;
391
+ });
392
+ if (selectedRoadmapIdRef.current) {
393
+ void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
394
+ }
395
+ opts?.onSuccess?.();
396
+ } catch (err) {
397
+ const error = err instanceof Error ? err : new Error("Failed to delete milestone");
398
+ opts?.onError?.(error);
399
+ throw error;
400
+ }
401
+ }, [fetchSelectedRoadmap]); // Uses refs internally
402
+
403
+ // Feature CRUD
404
+ const createFeature = useCallback(async (
405
+ milestoneId: string,
406
+ input: RoadmapFeatureCreateInput,
407
+ opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
408
+ ) => {
409
+ try {
410
+ const newFeature = await api.createRoadmapFeature(milestoneId, input, projectIdRef.current);
411
+ setFeaturesByMilestoneId((prev) => ({
412
+ ...prev,
413
+ [milestoneId]: [...(prev[milestoneId] || []), newFeature],
414
+ }));
415
+ if (selectedRoadmapIdRef.current) {
416
+ void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
417
+ }
418
+ opts?.onSuccess?.();
419
+ } catch (err) {
420
+ const error = err instanceof Error ? err : new Error("Failed to create feature");
421
+ opts?.onError?.(error);
422
+ throw error;
423
+ }
424
+ }, [fetchSelectedRoadmap]); // Uses refs internally
425
+
426
+ const updateFeature = useCallback(async (
427
+ featureId: string,
428
+ updates: RoadmapFeatureUpdateInput,
429
+ opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
430
+ ) => {
431
+ try {
432
+ const updated = await api.updateRoadmapFeature(featureId, updates, projectIdRef.current);
433
+ setFeaturesByMilestoneId((prev) => {
434
+ const updatedMap: Record<string, RoadmapFeature[]> = {};
435
+ for (const [milestoneId, features] of Object.entries(prev)) {
436
+ updatedMap[milestoneId] = features.map((f) => (f.id === featureId ? updated : f));
437
+ }
438
+ return updatedMap;
439
+ });
440
+ if (selectedRoadmapIdRef.current) {
441
+ void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
442
+ }
443
+ opts?.onSuccess?.();
444
+ } catch (err) {
445
+ const error = err instanceof Error ? err : new Error("Failed to update feature");
446
+ opts?.onError?.(error);
447
+ throw error;
448
+ }
449
+ }, [fetchSelectedRoadmap]); // Uses refs internally
450
+
451
+ const deleteFeature = useCallback(async (
452
+ featureId: string,
453
+ opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
454
+ ) => {
455
+ try {
456
+ await api.deleteRoadmapFeature(featureId, projectIdRef.current);
457
+ setFeaturesByMilestoneId((prev) => {
458
+ const updatedMap: Record<string, RoadmapFeature[]> = {};
459
+ for (const [milestoneId, features] of Object.entries(prev)) {
460
+ updatedMap[milestoneId] = features.filter((f) => f.id !== featureId);
461
+ }
462
+ return updatedMap;
463
+ });
464
+ if (selectedRoadmapIdRef.current) {
465
+ void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
466
+ }
467
+ opts?.onSuccess?.();
468
+ } catch (err) {
469
+ const error = err instanceof Error ? err : new Error("Failed to delete feature");
470
+ opts?.onError?.(error);
471
+ throw error;
472
+ }
473
+ }, [fetchSelectedRoadmap]); // Uses refs internally
474
+
475
+ // Milestone ordering
476
+ const reorderMilestones = useCallback(async (
477
+ roadmapId: string,
478
+ orderedMilestoneIds: string[],
479
+ opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
480
+ ) => {
481
+ // Save snapshot for rollback
482
+ const snapshot = milestonesRef.current;
483
+
484
+ // Optimistic update
485
+ const reordered = orderedMilestoneIds
486
+ .map((id) => snapshot.find((m) => m.id === id))
487
+ .filter((m): m is RoadmapMilestone => m !== undefined)
488
+ .map((m, index) => ({ ...m, orderIndex: index }));
489
+
490
+ setMilestones(reordered);
491
+
492
+ try {
493
+ await api.reorderRoadmapMilestones(roadmapId, orderedMilestoneIds, projectIdRef.current);
494
+ // Refresh to get server state
495
+ if (selectedRoadmapIdRef.current) {
496
+ void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
497
+ }
498
+ opts?.onSuccess?.();
499
+ } catch (err) {
500
+ // Rollback to snapshot
501
+ setMilestones(snapshot);
502
+ const error = err instanceof Error ? err : new Error("Failed to reorder milestones");
503
+ opts?.onError?.(error);
504
+ throw error;
505
+ }
506
+ }, [fetchSelectedRoadmap]); // Uses refs internally
507
+
508
+ // Feature ordering
509
+ const reorderFeatures = useCallback(async (
510
+ milestoneId: string,
511
+ orderedFeatureIds: string[],
512
+ opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
513
+ ) => {
514
+ // No-op suppression: if IDs are already in the same order, skip API call
515
+ const currentFeatures = featuresByMilestoneIdRef.current[milestoneId] || [];
516
+ const currentIds = currentFeatures.map((f) => f.id);
517
+ if (JSON.stringify(currentIds) === JSON.stringify(orderedFeatureIds)) {
518
+ opts?.onSuccess?.();
519
+ return;
520
+ }
521
+
522
+ // Save snapshot for rollback
523
+ const snapshot = featuresByMilestoneIdRef.current;
524
+
525
+ // Optimistic update
526
+ const reordered = orderedFeatureIds
527
+ .map((id) => currentFeatures.find((f) => f.id === id))
528
+ .filter((f): f is RoadmapFeature => f !== undefined)
529
+ .map((f, index) => ({ ...f, orderIndex: index }));
530
+
531
+ setFeaturesByMilestoneId((prev) => ({
532
+ ...prev,
533
+ [milestoneId]: reordered,
534
+ }));
535
+
536
+ try {
537
+ await api.reorderRoadmapFeatures(milestoneId, orderedFeatureIds, projectIdRef.current);
538
+ // Refresh to get server state
539
+ if (selectedRoadmapIdRef.current) {
540
+ void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
541
+ }
542
+ opts?.onSuccess?.();
543
+ } catch (err) {
544
+ // Rollback to snapshot
545
+ setFeaturesByMilestoneId(snapshot);
546
+ const error = err instanceof Error ? err : new Error("Failed to reorder features");
547
+ opts?.onError?.(error);
548
+ throw error;
549
+ }
550
+ }, [fetchSelectedRoadmap]); // Uses refs internally
551
+
552
+ const moveFeature = useCallback(async (
553
+ featureId: string,
554
+ targetMilestoneId: string,
555
+ targetIndex: number,
556
+ opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
557
+ ) => {
558
+ // Save snapshot for rollback
559
+ const snapshot = featuresByMilestoneIdRef.current;
560
+
561
+ // Find which milestone the feature is currently in
562
+ let sourceMilestoneId: string | null = null;
563
+ for (const [milestoneId, features] of Object.entries(snapshot)) {
564
+ if (features.some((f) => f.id === featureId)) {
565
+ sourceMilestoneId = milestoneId;
566
+ break;
567
+ }
568
+ }
569
+
570
+ if (!sourceMilestoneId) {
571
+ const error = new Error("Feature not found");
572
+ opts?.onError?.(error);
573
+ throw error;
574
+ }
575
+
576
+ // No-op suppression: if already at target position in same milestone, skip
577
+ if (sourceMilestoneId === targetMilestoneId) {
578
+ const currentFeatures = snapshot[sourceMilestoneId] || [];
579
+ const clampedIndex = Math.max(0, Math.min(targetIndex, currentFeatures.length - 1));
580
+ const currentIndex = currentFeatures.findIndex((f) => f.id === featureId);
581
+ if (currentIndex === clampedIndex) {
582
+ opts?.onSuccess?.();
583
+ return;
584
+ }
585
+ }
586
+
587
+ // Optimistic update
588
+ const sourceFeatures = snapshot[sourceMilestoneId] || [];
589
+ const targetFeatures = snapshot[targetMilestoneId] || [];
590
+ const feature = sourceFeatures.find((f) => f.id === featureId);
591
+
592
+ if (!feature) {
593
+ const error = new Error("Feature not found");
594
+ opts?.onError?.(error);
595
+ throw error;
596
+ }
597
+
598
+ // Remove from source
599
+ const newSourceFeatures = sourceFeatures
600
+ .filter((f) => f.id !== featureId)
601
+ .map((f, index) => ({ ...f, orderIndex: index }));
602
+
603
+ // Add to target at correct position
604
+ const updatedFeature = { ...feature, milestoneId: targetMilestoneId, orderIndex: targetIndex };
605
+ const newTargetFeatures = [...targetFeatures];
606
+ newTargetFeatures.splice(targetIndex, 0, updatedFeature);
607
+ // Renormalize target
608
+ const normalizedTargetFeatures = newTargetFeatures.map((f, index) => ({ ...f, orderIndex: index }));
609
+
610
+ // If moving within same milestone, update source with the new order
611
+ if (sourceMilestoneId === targetMilestoneId) {
612
+ setFeaturesByMilestoneId((prev) => ({
613
+ ...prev,
614
+ [sourceMilestoneId]: normalizedTargetFeatures,
615
+ }));
616
+ } else {
617
+ // Renormalize source after removal
618
+ setFeaturesByMilestoneId((prev) => ({
619
+ ...prev,
620
+ [sourceMilestoneId]: newSourceFeatures,
621
+ [targetMilestoneId]: normalizedTargetFeatures,
622
+ }));
623
+ }
624
+
625
+ try {
626
+ await api.moveRoadmapFeature(featureId, targetMilestoneId, targetIndex, projectId);
627
+ // Refresh to get server state
628
+ if (selectedRoadmapIdRef.current) {
629
+ void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
630
+ }
631
+ opts?.onSuccess?.();
632
+ } catch (err) {
633
+ // Rollback to snapshot
634
+ setFeaturesByMilestoneId(snapshot);
635
+ const error = err instanceof Error ? err : new Error("Failed to move feature");
636
+ opts?.onError?.(error);
637
+ throw error;
638
+ }
639
+ }, [fetchSelectedRoadmap, projectId]);
640
+
641
+ // ── Milestone Suggestion Actions (Ephemeral) ───────────────────────────────────
642
+
643
+ /**
644
+ * Generate a stable draft ID for suggestions.
645
+ * Uses crypto.randomUUID() for browser environments with a counter fallback.
646
+ */
647
+ function generateDraftId(): string {
648
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
649
+ return crypto.randomUUID();
650
+ }
651
+ // Fallback for environments without crypto.randomUUID
652
+ return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
653
+ }
654
+
655
+ const generateMilestoneSuggestions = useCallback(async (
656
+ goalPrompt: string,
657
+ count: number = 5,
658
+ opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
659
+ ) => {
660
+ const currentRoadmapId = selectedRoadmapIdRef.current;
661
+ if (!currentRoadmapId) {
662
+ const error = new Error("No roadmap selected");
663
+ opts?.onError?.(error);
664
+ throw error;
665
+ }
666
+
667
+ // Capture project context version for stale-response protection
668
+ const contextVersionAtStart = projectContextVersionRef.current;
669
+ const requestProjectId = projectIdRef.current;
670
+
671
+ setIsGeneratingSuggestions(true);
672
+
673
+ try {
674
+ const response = await api.generateMilestoneSuggestions(
675
+ currentRoadmapId,
676
+ goalPrompt,
677
+ count,
678
+ requestProjectId
679
+ );
680
+
681
+ // Check for stale response
682
+ if (projectContextVersionRef.current !== contextVersionAtStart) {
683
+ // Project context changed during fetch - discard response
684
+ return;
685
+ }
686
+
687
+ // Assign stable draft IDs to suggestions for UI binding and identity
688
+ const suggestionsWithIds: MilestoneSuggestion[] = response.suggestions.map((s) => ({
689
+ id: generateDraftId(),
690
+ title: s.title,
691
+ description: s.description,
692
+ }));
693
+ setMilestoneSuggestions(suggestionsWithIds);
694
+ opts?.onSuccess?.();
695
+ } catch (err) {
696
+ // Check for stale response
697
+ if (projectContextVersionRef.current !== contextVersionAtStart) {
698
+ // Project context changed during fetch - discard error
699
+ return;
700
+ }
701
+
702
+ const error = err instanceof Error ? err : new Error("Failed to generate suggestions");
703
+ opts?.onError?.(error);
704
+ throw error;
705
+ } finally {
706
+ // Only clear loading state if context hasn't changed
707
+ if (projectContextVersionRef.current === contextVersionAtStart) {
708
+ setIsGeneratingSuggestions(false);
709
+ }
710
+ }
711
+ }, []);
712
+
713
+ const updateMilestoneSuggestionDraft = useCallback((draftId: string, patch: SuggestionDraftPatch) => {
714
+ // Update ref first for immediate visibility to acceptAll
715
+ const currentSuggestions = milestoneSuggestionsRef.current;
716
+ const updatedSuggestions = currentSuggestions.map((s) => (s.id === draftId ? { ...s, ...patch } : s));
717
+ milestoneSuggestionsRef.current = updatedSuggestions;
718
+ // Then update state for re-render
719
+ setMilestoneSuggestions((prev) =>
720
+ prev.map((s) => (s.id === draftId ? { ...s, ...patch } : s))
721
+ );
722
+ }, []);
723
+
724
+ const acceptMilestoneSuggestion = useCallback(async (
725
+ draftId: string,
726
+ opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
727
+ ) => {
728
+ const currentRoadmapId = selectedRoadmapIdRef.current;
729
+ if (!currentRoadmapId) {
730
+ const error = new Error("No roadmap selected");
731
+ opts?.onError?.(error);
732
+ throw error;
733
+ }
734
+
735
+ // Capture state for stale-response protection
736
+ const contextVersionAtStart = projectContextVersionRef.current;
737
+ const currentSuggestions = milestoneSuggestionsRef.current;
738
+
739
+ // Find the suggestion by draft ID
740
+ const index = currentSuggestions.findIndex((s) => s.id === draftId);
741
+ if (index === -1) {
742
+ const error = new Error("Suggestion draft not found");
743
+ opts?.onError?.(error);
744
+ throw error;
745
+ }
746
+
747
+ const suggestion = currentSuggestions[index];
748
+
749
+ // Validate: title must not be empty/whitespace-only
750
+ if (!suggestion.title.trim()) {
751
+ const error = new Error("Title cannot be empty");
752
+ opts?.onError?.(error);
753
+ throw error;
754
+ }
755
+
756
+ // Optimistic update: remove from suggestions immediately
757
+ setMilestoneSuggestions((prev) => prev.filter((s) => s.id !== draftId));
758
+
759
+ try {
760
+ await api.createRoadmapMilestone(
761
+ currentRoadmapId,
762
+ { title: suggestion.title, description: suggestion.description },
763
+ projectIdRef.current
764
+ );
765
+
766
+ // Check for stale response
767
+ if (projectContextVersionRef.current !== contextVersionAtStart) {
768
+ // Project context changed - re-add to suggestions (optimistic rollback)
769
+ setMilestoneSuggestions((prev) => {
770
+ const updated = [...prev];
771
+ updated.splice(index, 0, suggestion);
772
+ return updated;
773
+ });
774
+ return;
775
+ }
776
+
777
+ // Refresh the roadmap to get the new milestone
778
+ if (selectedRoadmapIdRef.current) {
779
+ void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
780
+ }
781
+
782
+ opts?.onSuccess?.();
783
+ } catch (err) {
784
+ // Rollback: re-add to suggestions
785
+ setMilestoneSuggestions((prev) => {
786
+ const updated = [...prev];
787
+ updated.splice(index, 0, suggestion);
788
+ return updated;
789
+ });
790
+
791
+ const error = err instanceof Error ? err : new Error("Failed to accept suggestion");
792
+ opts?.onError?.(error);
793
+ throw error;
794
+ }
795
+ }, [fetchSelectedRoadmap]);
796
+
797
+ const acceptAllMilestoneSuggestions = useCallback(async (
798
+ opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
799
+ ) => {
800
+ const currentRoadmapId = selectedRoadmapIdRef.current;
801
+ if (!currentRoadmapId) {
802
+ const error = new Error("No roadmap selected");
803
+ opts?.onError?.(error);
804
+ throw error;
805
+ }
806
+
807
+ // Read from ref - it's updated synchronously by updateMilestoneSuggestionDraft
808
+ const suggestionsToAccept = [...milestoneSuggestionsRef.current];
809
+ if (suggestionsToAccept.length === 0) {
810
+ return;
811
+ }
812
+
813
+ // Validate all titles before accepting any
814
+ const emptyTitleIndex = suggestionsToAccept.findIndex((s) => !s.title.trim());
815
+ if (emptyTitleIndex !== -1) {
816
+ const error = new Error(`Title cannot be empty at position ${emptyTitleIndex + 1}`);
817
+ opts?.onError?.(error);
818
+ throw error;
819
+ }
820
+
821
+ // Clear suggestions immediately (optimistic)
822
+ setMilestoneSuggestions([]);
823
+
824
+ // Capture state for stale-response protection
825
+ const contextVersionAtStart = projectContextVersionRef.current;
826
+
827
+ // Accept sequentially to preserve order
828
+ for (let i = 0; i < suggestionsToAccept.length; i++) {
829
+ // Check for stale response
830
+ if (projectContextVersionRef.current !== contextVersionAtStart) {
831
+ // Project context changed - stop accepting
832
+ break;
833
+ }
834
+
835
+ const suggestion = suggestionsToAccept[i];
836
+
837
+ try {
838
+ await api.createRoadmapMilestone(
839
+ currentRoadmapId,
840
+ { title: suggestion.title, description: suggestion.description },
841
+ projectIdRef.current
842
+ );
843
+ } catch (err) {
844
+ // On error, stop accepting and report
845
+ const error = err instanceof Error ? err : new Error("Failed to accept all suggestions");
846
+ opts?.onError?.(error);
847
+ throw error;
848
+ }
849
+ }
850
+
851
+ // Check for stale response
852
+ if (projectContextVersionRef.current !== contextVersionAtStart) {
853
+ return;
854
+ }
855
+
856
+ // Refresh the roadmap to get all new milestones
857
+ if (selectedRoadmapIdRef.current) {
858
+ void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
859
+ }
860
+
861
+ opts?.onSuccess?.();
862
+ }, [fetchSelectedRoadmap]);
863
+
864
+ // ── Feature Suggestion Actions (Ephemeral, Milestone-Scoped) ───────────────────────────────────
865
+
866
+ const isGeneratingFeatureSuggestions = useCallback((milestoneId: string): boolean => {
867
+ return generatingFeatureSuggestionsRef.current[milestoneId] ?? false;
868
+ }, []);
869
+
870
+ const generateFeatureSuggestions = useCallback(async (
871
+ milestoneId: string,
872
+ input?: { prompt?: string; count?: number },
873
+ opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
874
+ ) => {
875
+ // Capture project context version for stale-response protection
876
+ const contextVersionAtStart = projectContextVersionRef.current;
877
+ const requestProjectId = projectIdRef.current;
878
+
879
+ // Set loading state for this milestone
880
+ setGeneratingFeatureSuggestions((prev) => ({ ...prev, [milestoneId]: true }));
881
+
882
+ try {
883
+ const response = await api.generateFeatureSuggestions(
884
+ milestoneId,
885
+ input,
886
+ requestProjectId
887
+ );
888
+
889
+ // Check for stale response
890
+ if (projectContextVersionRef.current !== contextVersionAtStart) {
891
+ // Project context changed during fetch - discard response
892
+ return;
893
+ }
894
+
895
+ // Assign stable draft IDs to suggestions for UI binding and identity
896
+ const suggestionsWithIds: FeatureSuggestion[] = response.suggestions.map((s) => ({
897
+ id: generateDraftId(),
898
+ title: s.title,
899
+ description: s.description,
900
+ }));
901
+ setFeatureSuggestionsByMilestoneId((prev) => ({
902
+ ...prev,
903
+ [milestoneId]: suggestionsWithIds,
904
+ }));
905
+ opts?.onSuccess?.();
906
+ } catch (err) {
907
+ // Check for stale response
908
+ if (projectContextVersionRef.current !== contextVersionAtStart) {
909
+ // Project context changed during fetch - discard error
910
+ return;
911
+ }
912
+
913
+ const error = err instanceof Error ? err : new Error("Failed to generate feature suggestions");
914
+ opts?.onError?.(error);
915
+ throw error;
916
+ } finally {
917
+ // Only clear loading state if context hasn't changed
918
+ if (projectContextVersionRef.current === contextVersionAtStart) {
919
+ setGeneratingFeatureSuggestions((prev) => ({ ...prev, [milestoneId]: false }));
920
+ }
921
+ }
922
+ }, []);
923
+
924
+ const updateFeatureSuggestionDraft = useCallback((milestoneId: string, draftId: string, patch: SuggestionDraftPatch) => {
925
+ // Update ref first for immediate visibility to acceptAll
926
+ const currentSuggestions = featureSuggestionsByMilestoneIdRef.current[milestoneId] || [];
927
+ const updatedSuggestions = currentSuggestions.map((s) => (s.id === draftId ? { ...s, ...patch } : s));
928
+ featureSuggestionsByMilestoneIdRef.current = {
929
+ ...featureSuggestionsByMilestoneIdRef.current,
930
+ [milestoneId]: updatedSuggestions,
931
+ };
932
+ // Then update state for re-render
933
+ setFeatureSuggestionsByMilestoneId((prev) => ({
934
+ ...prev,
935
+ [milestoneId]: prev[milestoneId]?.map((s) => (s.id === draftId ? { ...s, ...patch } : s)) || [],
936
+ }));
937
+ }, []);
938
+
939
+ const acceptFeatureSuggestion = useCallback(async (
940
+ milestoneId: string,
941
+ draftId: string,
942
+ opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
943
+ ) => {
944
+ // Capture state for stale-response protection
945
+ const contextVersionAtStart = projectContextVersionRef.current;
946
+ const currentSuggestions = featureSuggestionsByMilestoneIdRef.current[milestoneId] || [];
947
+
948
+ // Find the suggestion by draft ID
949
+ const index = currentSuggestions.findIndex((s) => s.id === draftId);
950
+ if (index === -1) {
951
+ const error = new Error("Suggestion draft not found");
952
+ opts?.onError?.(error);
953
+ throw error;
954
+ }
955
+
956
+ const suggestion = currentSuggestions[index];
957
+
958
+ // Validate: title must not be empty/whitespace-only
959
+ if (!suggestion.title.trim()) {
960
+ const error = new Error("Title cannot be empty");
961
+ opts?.onError?.(error);
962
+ throw error;
963
+ }
964
+
965
+ // Optimistic update: remove from suggestions immediately
966
+ setFeatureSuggestionsByMilestoneId((prev) => ({
967
+ ...prev,
968
+ [milestoneId]: prev[milestoneId]?.filter((s) => s.id !== draftId) || [],
969
+ }));
970
+
971
+ try {
972
+ await api.createRoadmapFeature(
973
+ milestoneId,
974
+ { title: suggestion.title, description: suggestion.description },
975
+ projectIdRef.current
976
+ );
977
+
978
+ // Check for stale response
979
+ if (projectContextVersionRef.current !== contextVersionAtStart) {
980
+ // Project context changed - re-add to suggestions (optimistic rollback)
981
+ setFeatureSuggestionsByMilestoneId((prev) => {
982
+ const milestoneSuggestions = prev[milestoneId] || [];
983
+ const updated = [...milestoneSuggestions];
984
+ updated.splice(index, 0, suggestion);
985
+ return { ...prev, [milestoneId]: updated };
986
+ });
987
+ return;
988
+ }
989
+
990
+ // Refresh the roadmap to get the new feature
991
+ if (selectedRoadmapIdRef.current) {
992
+ void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
993
+ }
994
+
995
+ opts?.onSuccess?.();
996
+ } catch (err) {
997
+ // Rollback: re-add to suggestions
998
+ setFeatureSuggestionsByMilestoneId((prev) => {
999
+ const milestoneSuggestions = prev[milestoneId] || [];
1000
+ const updated = [...milestoneSuggestions];
1001
+ updated.splice(index, 0, suggestion);
1002
+ return { ...prev, [milestoneId]: updated };
1003
+ });
1004
+
1005
+ const error = err instanceof Error ? err : new Error("Failed to accept suggestion");
1006
+ opts?.onError?.(error);
1007
+ throw error;
1008
+ }
1009
+ }, [fetchSelectedRoadmap]);
1010
+
1011
+ const acceptAllFeatureSuggestions = useCallback(async (
1012
+ milestoneId: string,
1013
+ opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
1014
+ ) => {
1015
+ // Read from ref - it's updated synchronously by updateFeatureSuggestionDraft
1016
+ const suggestionsToAccept = [...(featureSuggestionsByMilestoneIdRef.current[milestoneId] || [])];
1017
+ if (suggestionsToAccept.length === 0) {
1018
+ return;
1019
+ }
1020
+
1021
+ // Validate all titles before accepting any
1022
+ const emptyTitleIndex = suggestionsToAccept.findIndex((s) => !s.title.trim());
1023
+ if (emptyTitleIndex !== -1) {
1024
+ const error = new Error(`Title cannot be empty at position ${emptyTitleIndex + 1}`);
1025
+ opts?.onError?.(error);
1026
+ throw error;
1027
+ }
1028
+
1029
+ // Clear suggestions for this milestone immediately (optimistic)
1030
+ setFeatureSuggestionsByMilestoneId((prev) => ({
1031
+ ...prev,
1032
+ [milestoneId]: [],
1033
+ }));
1034
+
1035
+ // Capture state for stale-response protection
1036
+ const contextVersionAtStart = projectContextVersionRef.current;
1037
+
1038
+ // Accept sequentially to preserve order
1039
+ for (let i = 0; i < suggestionsToAccept.length; i++) {
1040
+ // Check for stale response
1041
+ if (projectContextVersionRef.current !== contextVersionAtStart) {
1042
+ // Project context changed - stop accepting
1043
+ break;
1044
+ }
1045
+
1046
+ const suggestion = suggestionsToAccept[i];
1047
+
1048
+ try {
1049
+ await api.createRoadmapFeature(
1050
+ milestoneId,
1051
+ { title: suggestion.title, description: suggestion.description },
1052
+ projectIdRef.current
1053
+ );
1054
+ } catch (err) {
1055
+ // On error, stop accepting and report
1056
+ const error = err instanceof Error ? err : new Error("Failed to accept all suggestions");
1057
+ opts?.onError?.(error);
1058
+ throw error;
1059
+ }
1060
+ }
1061
+
1062
+ // Check for stale response
1063
+ if (projectContextVersionRef.current !== contextVersionAtStart) {
1064
+ return;
1065
+ }
1066
+
1067
+ // Refresh the roadmap to get all new features
1068
+ if (selectedRoadmapIdRef.current) {
1069
+ void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
1070
+ }
1071
+
1072
+ opts?.onSuccess?.();
1073
+ }, [fetchSelectedRoadmap]);
1074
+
1075
+ const clearMilestoneSuggestions = useCallback(() => {
1076
+ setMilestoneSuggestions([]);
1077
+ setIsGeneratingSuggestions(false);
1078
+ }, []);
1079
+
1080
+ const clearFeatureSuggestions = useCallback((milestoneId: string) => {
1081
+ setFeatureSuggestionsByMilestoneId((prev) => {
1082
+ const updated = { ...prev };
1083
+ delete updated[milestoneId];
1084
+ return updated;
1085
+ });
1086
+ setGeneratingFeatureSuggestions((prev) => {
1087
+ const updated = { ...prev };
1088
+ delete updated[milestoneId];
1089
+ return updated;
1090
+ });
1091
+ }, []);
1092
+
1093
+ // ── Handoff / Export Functions ────────────────────────────────────────
1094
+
1095
+ const fetchHandoff = useCallback(async (
1096
+ roadmapId: string,
1097
+ opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
1098
+ ) => {
1099
+ const requestVersion = ++handoffFetchVersionRef.current;
1100
+ const requestProjectId = projectId; // Capture projectId at request time
1101
+
1102
+ setIsFetchingHandoff(true);
1103
+ setHandoffError(null);
1104
+
1105
+ try {
1106
+ const data = await api.fetchRoadmapHandoff(roadmapId, requestProjectId);
1107
+
1108
+ // Reject stale responses: check if project changed or version is stale
1109
+ if (handoffFetchVersionRef.current !== requestVersion || projectId !== requestProjectId) {
1110
+ return; // Stale response, discard
1111
+ }
1112
+
1113
+ setHandoffPayload(data);
1114
+ opts?.onSuccess?.();
1115
+ } catch (err) {
1116
+ // Reject stale errors: check if project changed or version is stale
1117
+ if (handoffFetchVersionRef.current !== requestVersion || projectId !== requestProjectId) {
1118
+ return; // Stale error, discard
1119
+ }
1120
+
1121
+ const error = err instanceof Error ? err : new Error(String(err));
1122
+ setHandoffError(error);
1123
+ setHandoffPayload(null);
1124
+ opts?.onError?.(error);
1125
+ } finally {
1126
+ // Only clear loading if this is still the current request
1127
+ if (handoffFetchVersionRef.current === requestVersion) {
1128
+ setIsFetchingHandoff(false);
1129
+ }
1130
+ }
1131
+ }, [projectId]);
1132
+
1133
+ const clearHandoff = useCallback(() => {
1134
+ setHandoffPayload(null);
1135
+ setHandoffError(null);
1136
+ setIsFetchingHandoff(false);
1137
+ }, []);
1138
+
1139
+ const refresh = useCallback(async () => {
1140
+ await fetchRoadmaps();
1141
+ if (selectedRoadmapIdRef.current) {
1142
+ await fetchSelectedRoadmap(selectedRoadmapIdRef.current);
1143
+ }
1144
+ }, [fetchRoadmaps, fetchSelectedRoadmap]);
1145
+
1146
+ return {
1147
+ roadmaps,
1148
+ selectedRoadmapId,
1149
+ selectedRoadmap,
1150
+ milestones,
1151
+ featuresByMilestoneId,
1152
+ loading,
1153
+ error,
1154
+ createRoadmap,
1155
+ updateRoadmap,
1156
+ deleteRoadmap,
1157
+ selectRoadmap,
1158
+ createMilestone,
1159
+ updateMilestone,
1160
+ deleteMilestone,
1161
+ reorderMilestones,
1162
+ createFeature,
1163
+ updateFeature,
1164
+ deleteFeature,
1165
+ reorderFeatures,
1166
+ moveFeature,
1167
+ milestoneSuggestions,
1168
+ isGeneratingSuggestions,
1169
+ generateMilestoneSuggestions,
1170
+ updateMilestoneSuggestionDraft,
1171
+ acceptMilestoneSuggestion,
1172
+ acceptAllMilestoneSuggestions,
1173
+ clearMilestoneSuggestions,
1174
+ featureSuggestionsByMilestoneId,
1175
+ isGeneratingFeatureSuggestions,
1176
+ generateFeatureSuggestions,
1177
+ updateFeatureSuggestionDraft,
1178
+ acceptFeatureSuggestion,
1179
+ acceptAllFeatureSuggestions,
1180
+ clearFeatureSuggestions,
1181
+ handoffPayload,
1182
+ isFetchingHandoff,
1183
+ handoffError,
1184
+ fetchHandoff,
1185
+ clearHandoff,
1186
+ refresh,
1187
+ };
1188
+ }