@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.
- package/dist/bin.js +30071 -20735
- package/dist/client/assets/AgentDetailView-BwJaLqZh.css +1 -0
- package/dist/client/assets/AgentDetailView-gy_5SUj2.js +18 -0
- package/dist/client/assets/AgentsView-BkB9FiMT.js +29 -0
- package/dist/client/assets/AgentsView-CV3vm7Qk.css +1 -0
- package/dist/client/assets/ChatView-B_-B8fqu.js +1 -0
- package/dist/client/assets/ChatView-DwJAd5G1.css +1 -0
- package/dist/client/assets/{DevServerView-l8RCyL2k.js → DevServerView-BkvtjZBa.js} +1 -1
- package/dist/client/assets/{DirectoryPicker-CS1dwqcC.js → DirectoryPicker-BK-KbnhP.js} +1 -1
- package/dist/client/assets/{DocumentsView-DmthQWDZ.js → DocumentsView-BEg1CQAk.js} +1 -1
- package/dist/client/assets/{DocumentsView-BrhyOdeE.css → DocumentsView-gv4zG3aT.css} +1 -1
- package/dist/client/assets/EvalsView-Berf9bQm.js +1 -0
- package/dist/client/assets/EvalsView-CUNJ1TLc.css +1 -0
- package/dist/client/assets/{agentSkills-DDHJnrkn.css → ExperimentalAgentOnboardingModal-B-APN_lM.css} +1 -1
- package/dist/client/assets/ExperimentalAgentOnboardingModal-jcInE50G.js +499 -0
- package/dist/client/assets/InsightsView-B0J4mhzV.css +1 -0
- package/dist/client/assets/InsightsView-BX5bSF1J.js +11 -0
- package/dist/client/assets/{MemoryView-CPwlKnUI.js → MemoryView-CKElJY_3.js} +2 -2
- package/dist/client/assets/NodesView-DLUOBLf6.js +14 -0
- package/dist/client/assets/NodesView-DT4pXowv.css +1 -0
- package/dist/client/assets/{PiExtensionsManager-j8rPXqmB.js → PiExtensionsManager-COlJf0Kx.js} +2 -2
- package/dist/client/assets/PluginManager-CfW55BF4.js +1 -0
- package/dist/client/assets/PluginManager-DtRQXia5.css +1 -0
- package/dist/client/assets/{ResearchView-D9DNJYDq.js → ResearchView-B256Lr8I.js} +1 -1
- package/dist/client/assets/SettingsModal-BeA_nQtW.js +31 -0
- package/dist/client/assets/SettingsModal-DzsLquBu.css +1 -0
- package/dist/client/assets/{SettingsModal-fxvTFLtR.js → SettingsModal-yRqM4DV8.js} +1 -1
- package/dist/client/assets/SetupWizardModal-uUZk3TKT.js +1 -0
- package/dist/client/assets/{SkillsView-Ddf0YL8z.js → SkillsView-CP8JX0P_.js} +1 -1
- package/dist/client/assets/TodoView-Cx9cVhq7.css +1 -0
- package/dist/client/assets/TodoView-DCRIkDZ-.js +6 -0
- package/dist/client/assets/createLucideIcon-BazL2hk5.js +21 -0
- package/dist/client/assets/dashboard-view-BkTMSZYn.css +1 -0
- package/dist/client/assets/dashboard-view-CyWN-d02.js +63 -0
- package/dist/client/assets/dashboard-view-lR7YYmSC.js +21 -0
- package/dist/client/assets/{folder-open-BiJpmnaT.js → folder-open-DHjELt8-.js} +1 -1
- package/dist/client/assets/index-CQyVRLOb.js +692 -0
- package/dist/client/assets/index-CxA2Nn0_.css +1 -0
- package/dist/client/assets/projectDetection-G3XuxD2X.js +1 -0
- package/dist/client/assets/{star-BwRZmiuZ.js → star-DYesq1AV.js} +1 -1
- package/dist/client/assets/{upload-D4NwZhPp.js → upload-DTWF3Db5.js} +1 -1
- package/dist/client/assets/{users-DNISDtI1.js → users--syrel4l.js} +1 -1
- package/dist/client/index.html +12 -20
- package/dist/client/theme-data.css +106 -0
- package/dist/client/version.json +1 -1
- package/dist/droid-cli/package.json +1 -1
- package/dist/extension.js +17072 -9627
- package/dist/pi-claude-cli/package.json +1 -1
- package/dist/plugins/fusion-plugin-cursor-runtime/bundled.js +218 -0
- package/dist/plugins/fusion-plugin-cursor-runtime/manifest.json +6 -0
- package/dist/plugins/fusion-plugin-cursor-runtime/package.json +11 -0
- package/dist/plugins/fusion-plugin-dependency-graph/manifest.json +1 -1
- package/dist/plugins/fusion-plugin-dependency-graph/package.json +6 -4
- package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.css +58 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.tsx +301 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/GraphHighlight.css +27 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.css +157 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx +126 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/GraphToolbar.css +35 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/GraphToolbar.tsx +36 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraph.highlighting.test.tsx +112 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraph.persistence.test.tsx +115 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraph.test.tsx +128 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphTaskNode.drag.test.tsx +82 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphTaskNode.test.tsx +307 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphToolbar.test.tsx +60 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/edges.test.tsx +75 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/filtering.test.tsx +62 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/filters.test.ts +78 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/graphPositionStorage.test.ts +95 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/host-integration.test.ts +74 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/index.test.ts +58 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/interactions.test.tsx +121 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/layout.test.ts +70 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/persistence.test.tsx +89 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/useGraphData.test.ts +86 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/useGraphInteraction.test.ts +167 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/useGraphPositions.test.ts +66 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/useNodeDrag.test.ts +81 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/dashboard-interop.d.ts +35 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/dashboard-view.tsx +19 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/edges.tsx +70 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/filters.ts +8 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/hooks/__tests__/useDependencyChain.test.ts +53 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/hooks/useDependencyChain.ts +60 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/hooks/useGraphPositions.ts +45 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/hooks/useNodeDrag.ts +114 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/index.ts +1 -2
- package/dist/plugins/fusion-plugin-dependency-graph/src/layout.ts +91 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/styles/drag.css +15 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/types.ts +21 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/useGraphData.ts +17 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/useGraphInteraction.ts +292 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/utils/graphPositionStorage.ts +65 -0
- package/dist/plugins/fusion-plugin-droid-runtime/bundled.js +136680 -0
- package/dist/plugins/fusion-plugin-droid-runtime/manifest.json +13 -0
- package/dist/plugins/fusion-plugin-droid-runtime/mcp-schema-server.cjs +49 -0
- package/dist/plugins/fusion-plugin-droid-runtime/package.json +11 -0
- package/dist/plugins/fusion-plugin-hermes-runtime/bundled.js +176 -7
- package/dist/plugins/fusion-plugin-hermes-runtime/package.json +1 -1
- package/dist/plugins/fusion-plugin-openclaw-runtime/bundled.js +93 -6
- package/dist/plugins/fusion-plugin-openclaw-runtime/mcp-schema-server.cjs +59 -0
- package/dist/plugins/fusion-plugin-openclaw-runtime/package.json +1 -1
- package/dist/plugins/fusion-plugin-paperclip-runtime/package.json +1 -1
- package/dist/plugins/fusion-plugin-reports/manifest.json +33 -0
- package/dist/plugins/fusion-plugin-reports/package.json +26 -0
- package/dist/plugins/fusion-plugin-reports/src/__tests__/manifest.test.ts +51 -0
- package/dist/plugins/fusion-plugin-reports/src/__tests__/review-panel.test.ts +166 -0
- package/dist/plugins/fusion-plugin-reports/src/__tests__/settings.test.ts +157 -0
- package/dist/plugins/fusion-plugin-reports/src/index.ts +41 -0
- package/dist/plugins/fusion-plugin-reports/src/review-panel.ts +294 -0
- package/dist/plugins/fusion-plugin-reports/src/review-types.ts +75 -0
- package/dist/plugins/fusion-plugin-reports/src/settings.ts +105 -0
- package/dist/plugins/fusion-plugin-roadmap/manifest.json +16 -0
- package/dist/plugins/fusion-plugin-roadmap/package.json +48 -0
- package/dist/plugins/fusion-plugin-roadmap/src/__tests__/api-client.test.ts +101 -0
- package/dist/plugins/fusion-plugin-roadmap/src/__tests__/index.test.ts +92 -0
- package/dist/plugins/fusion-plugin-roadmap/src/__tests__/roadmap-routes.test.ts +48 -0
- package/dist/plugins/fusion-plugin-roadmap/src/__tests__/roadmap-suggestions.test.ts +31 -0
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.css +1299 -0
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.tsx +2559 -0
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/__tests__/RoadmapsView.test.tsx +1144 -0
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/__tests__/useRoadmaps.test.ts +1756 -0
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/api.ts +70 -0
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/test-setup.ts +7 -0
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/types.ts +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/useConfirm.ts +8 -0
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/useRoadmaps.ts +1188 -0
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/useViewportMode.ts +20 -0
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard-view.tsx +6 -0
- package/dist/plugins/fusion-plugin-roadmap/src/index.ts +74 -0
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-routes.ts +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-schema.ts +41 -0
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-suggestions.d.ts +15 -0
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-suggestions.ts +15 -0
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts +283 -0
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts.map +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.js +21 -0
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.js.map +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.ts +310 -0
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts +5 -0
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts.map +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js +361 -0
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js.map +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.ts +408 -0
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts +68 -0
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts.map +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js +300 -0
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js.map +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.ts +381 -0
- package/dist/plugins/fusion-plugin-roadmap/src/server/index.d.ts +3 -0
- package/dist/plugins/fusion-plugin-roadmap/src/server/index.ts +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-handoff.test.ts +445 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-ordering.test.ts +334 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts +1318 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-handoff.ts +163 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts +37 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts.map +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js +188 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js.map +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.ts +311 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts +299 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts.map +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.js +765 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.js.map +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.ts +1001 -0
- package/dist/plugins/fusion-plugin-whatsapp-chat/manifest.json +8 -0
- package/dist/plugins/fusion-plugin-whatsapp-chat/package.json +34 -0
- package/dist/plugins/fusion-plugin-whatsapp-chat/src/__tests__/auth-state.test.ts +99 -0
- package/dist/plugins/fusion-plugin-whatsapp-chat/src/__tests__/connection.test.ts +145 -0
- package/dist/plugins/fusion-plugin-whatsapp-chat/src/__tests__/index.test.ts +216 -0
- package/dist/plugins/fusion-plugin-whatsapp-chat/src/__tests__/reply.test.ts +52 -0
- package/dist/plugins/fusion-plugin-whatsapp-chat/src/auth-state.ts +89 -0
- package/dist/plugins/fusion-plugin-whatsapp-chat/src/connection.ts +253 -0
- package/dist/plugins/fusion-plugin-whatsapp-chat/src/index.ts +262 -0
- package/dist/plugins/fusion-plugin-whatsapp-chat/src/qrcode.d.ts +1 -0
- package/dist/plugins/fusion-plugin-whatsapp-chat/src/reply.ts +37 -0
- package/package.json +2 -2
- package/skill/fusion/SKILL.md +2 -2
- package/skill/fusion/references/engine-tools.md +8 -2
- package/skill/fusion/references/extension-tools.md +39 -0
- package/skill/fusion/references/fusion-capabilities.md +3 -0
- package/dist/client/assets/AgentDetailView-BKKpbp1S.js +0 -18
- package/dist/client/assets/AgentDetailView-CeO_1MK7.css +0 -1
- package/dist/client/assets/AgentsView-BRXFmrcJ.js +0 -527
- package/dist/client/assets/AgentsView-Bs03ptrd.css +0 -1
- package/dist/client/assets/ChatView-D7L2e_qu.js +0 -1
- package/dist/client/assets/InsightsView-AWo5o_81.css +0 -1
- package/dist/client/assets/InsightsView-DvXpMKmH.js +0 -11
- package/dist/client/assets/NodesView-BLlfUfsy.js +0 -14
- package/dist/client/assets/NodesView-fXqDk9ur.css +0 -1
- package/dist/client/assets/PluginManager-DA_T0GHn.css +0 -1
- package/dist/client/assets/PluginManager-pW6RMz5z.js +0 -1
- package/dist/client/assets/RoadmapsView-Djc_X35v.js +0 -6
- package/dist/client/assets/SettingsModal-BWe0KrGY.css +0 -1
- package/dist/client/assets/SettingsModal-WGCF_pk8.js +0 -31
- package/dist/client/assets/SetupWizardModal-tG_MF_nA.js +0 -1
- package/dist/client/assets/agentSkills-EwIwBlG8.js +0 -1
- package/dist/client/assets/index-D6ebxTPF.css +0 -1
- package/dist/client/assets/index-DYDLmOcK.js +0 -694
- package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraphView.css +0 -132
- package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraphView.tsx +0 -428
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraphView.test.tsx +0 -261
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/storage.test.ts +0 -31
- package/dist/plugins/fusion-plugin-dependency-graph/src/storage.ts +0 -23
- /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
|
+
}
|