@runfusion/fusion 0.25.0 → 0.26.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 (113) hide show
  1. package/README.md +6 -0
  2. package/dist/bin.js +47492 -45420
  3. package/dist/client/assets/{AgentDetailView-ZbHEbYRT.js → AgentDetailView-Cv-vgOj3.js} +3 -3
  4. package/dist/client/assets/{AgentsView-B3jYk8Kt.js → AgentsView-D6Zi5zfP.js} +4 -4
  5. package/dist/client/assets/ChatView-CAHjY9uO.js +1 -0
  6. package/dist/client/assets/{DevServerView-DyGDEiBP.js → DevServerView--_WBvIDQ.js} +1 -1
  7. package/dist/client/assets/{DirectoryPicker-D5UIeIl6.js → DirectoryPicker-xedtR-Rd.js} +1 -1
  8. package/dist/client/assets/{DocumentsView-DNHu1T8K.js → DocumentsView-Bg2oaZks.js} +1 -1
  9. package/dist/client/assets/{EvalsView-CpRobtDi.js → EvalsView-B3uOCXfr.js} +1 -1
  10. package/dist/client/assets/{ExperimentalAgentOnboardingModal-DOY_oZi7.js → ExperimentalAgentOnboardingModal-Bx6yXVS5.js} +1 -1
  11. package/dist/client/assets/{InsightsView-vp0RE8Mg.js → InsightsView-Q1zvtF4F.js} +1 -1
  12. package/dist/client/assets/{MemoryView-PSc5lGJt.js → MemoryView-xcN_eouf.js} +1 -1
  13. package/dist/client/assets/{NodesView-DMj6HGeC.js → NodesView-RxXg58_Q.js} +1 -1
  14. package/dist/client/assets/{PiExtensionsManager-DL_QcN56.js → PiExtensionsManager-Cc8aAZXg.js} +2 -2
  15. package/dist/client/assets/{PluginManager-BtYKm8IT.js → PluginManager-BEkyBajl.js} +1 -1
  16. package/dist/client/assets/{ResearchView-BzCcDAS4.css → ResearchView-BEI4ZSGs.css} +1 -1
  17. package/dist/client/assets/ResearchView-CERNf7sJ.js +1 -0
  18. package/dist/client/assets/{SettingsModal-CUCyaAyE.js → SettingsModal-B1r0yASu.js} +1 -1
  19. package/dist/client/assets/SettingsModal-BLsac7CJ.js +31 -0
  20. package/dist/client/assets/SettingsModal-Cis-4Lot.css +1 -0
  21. package/dist/client/assets/{SetupWizardModal-BKscasuh.js → SetupWizardModal-D1q548_L.js} +1 -1
  22. package/dist/client/assets/{SkillsView-BdELqTy7.js → SkillsView-ClLM6u6p.js} +1 -1
  23. package/dist/client/assets/StashRecoveryView-B_8WIQEo.css +1 -0
  24. package/dist/client/assets/StashRecoveryView-ze0pEZ5U.js +1 -0
  25. package/dist/client/assets/{TodoView-DFNGBDNV.js → TodoView-CTmIfy2M.js} +1 -1
  26. package/dist/client/assets/createLucideIcon-BazL2hk5.js +21 -0
  27. package/dist/client/assets/dashboard-view-4xAN3yO5.js +21 -0
  28. package/dist/client/assets/dashboard-view-BkTMSZYn.css +1 -0
  29. package/dist/client/assets/dashboard-view-CyWN-d02.js +63 -0
  30. package/dist/client/assets/dashboard-view-DdGlfuu-.css +1 -0
  31. package/dist/client/assets/{folder-open-k1xmUMyr.js → folder-open-BZuKESeq.js} +1 -1
  32. package/dist/client/assets/index-Bdw6llW6.js +692 -0
  33. package/dist/client/assets/index-CZGlyJuS.css +1 -0
  34. package/dist/client/assets/{star-ne32r3Y4.js → star-D75YKEq-.js} +1 -1
  35. package/dist/client/assets/{upload-MS-2Gx53.js → upload-BYYTgWFj.js} +1 -1
  36. package/dist/client/assets/{users-C519GSjH.js → users-RS90Aii3.js} +1 -1
  37. package/dist/client/index.html +2 -2
  38. package/dist/client/version.json +1 -1
  39. package/dist/droid-cli/package.json +1 -1
  40. package/dist/extension.js +4340 -3059
  41. package/dist/pi-claude-cli/package.json +1 -1
  42. package/dist/plugins/fusion-plugin-cli-printing-press/manifest.json +6 -0
  43. package/dist/plugins/fusion-plugin-cli-printing-press/package.json +26 -0
  44. package/dist/plugins/fusion-plugin-cli-printing-press/src/__tests__/manifest.test.ts +20 -0
  45. package/dist/plugins/fusion-plugin-cli-printing-press/src/index.ts +14 -0
  46. package/dist/plugins/fusion-plugin-cursor-runtime/package.json +1 -1
  47. package/dist/plugins/fusion-plugin-dependency-graph/package.json +1 -1
  48. package/dist/plugins/fusion-plugin-droid-runtime/package.json +1 -1
  49. package/dist/plugins/fusion-plugin-hermes-runtime/package.json +1 -1
  50. package/dist/plugins/fusion-plugin-openclaw-runtime/package.json +1 -1
  51. package/dist/plugins/fusion-plugin-paperclip-runtime/package.json +1 -1
  52. package/dist/plugins/fusion-plugin-reports/package.json +1 -1
  53. package/dist/plugins/fusion-plugin-roadmap/{src/dashboard/RoadmapsView.css → bundled.css} +13 -219
  54. package/dist/plugins/fusion-plugin-roadmap/bundled.js +29535 -0
  55. package/dist/plugins/fusion-plugin-roadmap/package.json +4 -41
  56. package/dist/plugins/fusion-plugin-whatsapp-chat/package.json +1 -1
  57. package/package.json +2 -3
  58. package/dist/client/assets/ChatView-DhPkiEGs.js +0 -1
  59. package/dist/client/assets/ResearchView-BhWqfdV0.js +0 -1
  60. package/dist/client/assets/SettingsModal-BAgB4_AR.js +0 -31
  61. package/dist/client/assets/SettingsModal-DzsLquBu.css +0 -1
  62. package/dist/client/assets/index-Qq2JOOWx.css +0 -1
  63. package/dist/client/assets/index-TFYXEVpn.js +0 -692
  64. package/dist/plugins/fusion-plugin-roadmap/src/__tests__/api-client.test.ts +0 -101
  65. package/dist/plugins/fusion-plugin-roadmap/src/__tests__/index.test.ts +0 -92
  66. package/dist/plugins/fusion-plugin-roadmap/src/__tests__/roadmap-routes.test.ts +0 -48
  67. package/dist/plugins/fusion-plugin-roadmap/src/__tests__/roadmap-suggestions.test.ts +0 -31
  68. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.tsx +0 -2559
  69. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/__tests__/RoadmapsView.test.tsx +0 -1144
  70. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/__tests__/useRoadmaps.test.ts +0 -1756
  71. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/api.ts +0 -70
  72. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/test-setup.ts +0 -7
  73. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/types.ts +0 -1
  74. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/useConfirm.ts +0 -8
  75. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/useRoadmaps.ts +0 -1188
  76. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/useViewportMode.ts +0 -20
  77. package/dist/plugins/fusion-plugin-roadmap/src/dashboard-view.tsx +0 -6
  78. package/dist/plugins/fusion-plugin-roadmap/src/index.ts +0 -74
  79. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-routes.ts +0 -1
  80. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-schema.ts +0 -41
  81. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-suggestions.d.ts +0 -15
  82. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-suggestions.ts +0 -15
  83. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts +0 -283
  84. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts.map +0 -1
  85. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.js +0 -21
  86. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.js.map +0 -1
  87. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.ts +0 -310
  88. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts +0 -5
  89. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts.map +0 -1
  90. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js +0 -361
  91. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js.map +0 -1
  92. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.ts +0 -408
  93. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts +0 -68
  94. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts.map +0 -1
  95. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js +0 -300
  96. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js.map +0 -1
  97. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.ts +0 -381
  98. package/dist/plugins/fusion-plugin-roadmap/src/server/index.d.ts +0 -3
  99. package/dist/plugins/fusion-plugin-roadmap/src/server/index.ts +0 -1
  100. package/dist/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-handoff.test.ts +0 -445
  101. package/dist/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-ordering.test.ts +0 -334
  102. package/dist/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts +0 -1318
  103. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-handoff.ts +0 -163
  104. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts +0 -37
  105. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts.map +0 -1
  106. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js +0 -188
  107. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js.map +0 -1
  108. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.ts +0 -311
  109. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts +0 -299
  110. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts.map +0 -1
  111. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.js +0 -765
  112. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.js.map +0 -1
  113. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.ts +0 -1001
@@ -1,2559 +0,0 @@
1
- import React, { useState, useCallback, useEffect, useRef } from "react";
2
- import { Plus, Pencil, Trash2, Check, X, GripVertical, Sparkles, Download, Copy, Loader, ArrowLeft, ChevronUp } from "lucide-react";
3
- import "./RoadmapsView.css";
4
- import type { ToastType } from "./types.js";
5
- import { useRoadmaps, type FeatureSuggestion, type MilestoneSuggestion, type SuggestionDraftPatch } from "./useRoadmaps.js";
6
- import { useViewportMode } from "./useViewportMode.js";
7
- import { useConfirm } from "./useConfirm.js";
8
- import type {
9
- Roadmap,
10
- RoadmapMilestone,
11
- RoadmapFeature,
12
- RoadmapCreateInput,
13
- RoadmapUpdateInput,
14
- RoadmapMilestoneCreateInput,
15
- RoadmapMilestoneUpdateInput,
16
- RoadmapFeatureCreateInput,
17
- RoadmapFeatureUpdateInput,
18
- RoadmapMissionPlanningHandoff,
19
- RoadmapFeatureTaskPlanningHandoff,
20
- } from "../roadmap-types.js";
21
-
22
- export interface RoadmapsViewProps {
23
- projectId?: string;
24
- addToast: (message: string, type?: ToastType) => void;
25
- }
26
-
27
- // ── Drag State Types ────────────────────────────────────────────────
28
-
29
- interface MilestoneDragState {
30
- draggingId: string | null;
31
- dropTargetId: string | null;
32
- dropPosition: "before" | "after" | null;
33
- }
34
-
35
- interface FeatureDragState {
36
- draggingId: string | null;
37
- draggingMilestoneId: string | null;
38
- dropTargetMilestoneId: string | null;
39
- dropTargetIndex: number | null;
40
- dropPosition: "before" | "after" | null;
41
- }
42
-
43
- // ── Inline Edit State Types ─────────────────────────────────────────
44
-
45
- interface InlineEditState {
46
- roadmapId: string | null;
47
- field: "title" | "description" | null;
48
- value: string;
49
- }
50
-
51
- interface MilestoneInlineEditState {
52
- milestoneId: string | null;
53
- field: "title" | "description" | null;
54
- value: string;
55
- }
56
-
57
- interface FeatureInlineEditState {
58
- featureId: string | null;
59
- field: "title" | "description" | null;
60
- value: string;
61
- }
62
-
63
- // ── Create Form State ───────────────────────────────────────────────
64
-
65
- interface CreateFormState {
66
- type: "roadmap" | "milestone" | "feature" | null;
67
- parentId?: string;
68
- title: string;
69
- description: string;
70
- }
71
-
72
- // ── Handoff Modal Types ─────────────────────────────────────────────
73
-
74
- interface HandoffModalProps {
75
- isOpen: boolean;
76
- onClose: () => void;
77
- roadmapId: string;
78
- roadmapTitle: string;
79
- handoffPayload: { mission: RoadmapMissionPlanningHandoff; features: RoadmapFeatureTaskPlanningHandoff[] } | null;
80
- isLoading: boolean;
81
- error: Error | null;
82
- onFetchHandoff: () => void;
83
- onCopyToClipboard: () => void;
84
- }
85
-
86
- // ── Handoff Modal Component ─────────────────────────────────────────
87
-
88
- function HandoffModal({
89
- isOpen,
90
- onClose,
91
- roadmapTitle,
92
- handoffPayload,
93
- isLoading,
94
- error,
95
- onFetchHandoff,
96
- onCopyToClipboard,
97
- }: HandoffModalProps) {
98
- if (!isOpen) return null;
99
-
100
- return (
101
- <div className="modal-overlay open" onClick={onClose} role="presentation">
102
- <div className="modal modal-lg" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="handoff-modal-title">
103
- <div className="modal-header">
104
- <h2 id="handoff-modal-title">Export Roadmap: {roadmapTitle}</h2>
105
- <button className="modal-close" onClick={onClose} aria-label="Close modal">
106
- <X size={18} />
107
- </button>
108
- </div>
109
- <div className="modal-body">
110
- <p className="text-muted roadmaps-view__handoff-intro">
111
- Export roadmap data for use in mission and task planning flows.
112
- This is a read-only export — no missions or tasks will be created.
113
- </p>
114
-
115
- {error && (
116
- <div className="form-error roadmaps-view__handoff-error">
117
- Error loading handoff data: {error.message}
118
- </div>
119
- )}
120
-
121
- {!handoffPayload && !isLoading && (
122
- <div className="roadmaps-view__handoff-empty-state">
123
- <button className="btn btn-primary" onClick={onFetchHandoff}>
124
- <Download size={16} className="roadmaps-view__handoff-button-icon" />
125
- Load Handoff Data
126
- </button>
127
- </div>
128
- )}
129
-
130
- {isLoading && (
131
- <div className="roadmaps-view__handoff-loading-state">
132
- <Loader size={24} className="spin" />
133
- <p className="roadmaps-view__handoff-loading-text">Loading handoff data...</p>
134
- </div>
135
- )}
136
-
137
- {handoffPayload && (
138
- <>
139
- <div className="roadmaps-view__handoff-section">
140
- <h3 className="roadmaps-view__handoff-section-title">Mission Planning Handoff</h3>
141
- <div className="card roadmaps-view__handoff-card">
142
- <pre className="roadmaps-view__handoff-pre roadmaps-view__handoff-pre--mission">
143
- {JSON.stringify(handoffPayload.mission, null, 2)}
144
- </pre>
145
- </div>
146
- </div>
147
-
148
- <div className="roadmaps-view__handoff-section">
149
- <h3 className="roadmaps-view__handoff-section-title">
150
- Feature Task Planning Handoffs ({handoffPayload.features.length})
151
- </h3>
152
- <div className="card roadmaps-view__handoff-card">
153
- <pre className="roadmaps-view__handoff-pre roadmaps-view__handoff-pre--features">
154
- {JSON.stringify(handoffPayload.features, null, 2)}
155
- </pre>
156
- </div>
157
- </div>
158
- </>
159
- )}
160
- </div>
161
- <div className="modal-actions">
162
- <div className="modal-actions-left">
163
- {handoffPayload && (
164
- <button className="btn btn-sm" onClick={onCopyToClipboard}>
165
- <Copy size={14} className="roadmaps-view__handoff-copy-icon" />
166
- Copy to Clipboard
167
- </button>
168
- )}
169
- </div>
170
- <div className="modal-actions-right">
171
- <button className="btn" onClick={onClose}>Close</button>
172
- </div>
173
- </div>
174
- </div>
175
- </div>
176
- );
177
- }
178
-
179
- // ── Roadmap Item ─────────────────────────────────────────────────────
180
-
181
- function RoadmapItem({
182
- roadmap,
183
- isSelected,
184
- onSelect,
185
- onEdit,
186
- onDelete,
187
- onExport,
188
- }: {
189
- roadmap: Roadmap;
190
- isSelected: boolean;
191
- onSelect: () => void;
192
- onEdit: () => void;
193
- onDelete: () => void;
194
- onExport: () => void;
195
- }) {
196
- const handleKeyDown = (e: React.KeyboardEvent) => {
197
- if (e.key === "Enter") {
198
- onSelect();
199
- }
200
- };
201
-
202
- const handleEditClick = (e: React.MouseEvent) => {
203
- e.stopPropagation();
204
- onEdit();
205
- };
206
-
207
- const handleDeleteClick = (e: React.MouseEvent) => {
208
- e.stopPropagation();
209
- onDelete();
210
- };
211
-
212
- const handleExportClick = (e: React.MouseEvent) => {
213
- e.stopPropagation();
214
- onExport();
215
- };
216
-
217
- return (
218
- <div
219
- className={`roadmaps-view__sidebar-item${isSelected ? " roadmaps-view__sidebar-item--active" : ""}`}
220
- onClick={onSelect}
221
- onKeyDown={handleKeyDown}
222
- role="button"
223
- tabIndex={0}
224
- aria-selected={isSelected}
225
- data-testid={`roadmap-item-${roadmap.id}`}
226
- >
227
- <div className="roadmaps-view__sidebar-item-content">
228
- <div className="roadmaps-view__sidebar-item-title">{roadmap.title}</div>
229
- {roadmap.description && (
230
- <div className="roadmaps-view__sidebar-item-desc">{roadmap.description}</div>
231
- )}
232
- </div>
233
- <div className="roadmaps-view__sidebar-item-actions" onClick={handleEditClick} role="presentation">
234
- <button
235
- className="roadmaps-view__icon-btn"
236
- onClick={handleExportClick}
237
- title="Export roadmap"
238
- aria-label="Export roadmap"
239
- data-testid={`roadmap-export-${roadmap.id}`}
240
- type="button"
241
- >
242
- <Download size={14} />
243
- </button>
244
- <button
245
- className="roadmaps-view__icon-btn"
246
- onClick={handleEditClick}
247
- title="Edit roadmap"
248
- aria-label="Edit roadmap"
249
- data-testid={`roadmap-edit-${roadmap.id}`}
250
- type="button"
251
- >
252
- <Pencil size={14} />
253
- </button>
254
- <button
255
- className="roadmaps-view__icon-btn roadmaps-view__icon-btn--danger"
256
- onClick={handleDeleteClick}
257
- title="Delete roadmap"
258
- aria-label="Delete roadmap"
259
- data-testid={`roadmap-delete-${roadmap.id}`}
260
- type="button"
261
- >
262
- <Trash2 size={14} />
263
- </button>
264
- </div>
265
- </div>
266
- );
267
- }
268
-
269
- // ── Mobile Roadmap List ──────────────────────────────────────────────
270
-
271
- function MobileRoadmapList({
272
- roadmaps,
273
- selectedRoadmapId,
274
- onSelect,
275
- onCreate,
276
- onEdit,
277
- onDelete,
278
- onExport,
279
- showCreateForm,
280
- onCancelCreate,
281
- onSaveCreate,
282
- }: {
283
- roadmaps: Roadmap[];
284
- selectedRoadmapId: string | null;
285
- onSelect: (id: string) => void;
286
- onCreate: () => void;
287
- onEdit: (roadmap: Roadmap) => void;
288
- onDelete: (roadmapId: string) => void;
289
- onExport: (roadmap: Roadmap) => void;
290
- showCreateForm: boolean;
291
- onCancelCreate: () => void;
292
- onSaveCreate: (input: RoadmapCreateInput) => void;
293
- }) {
294
- return (
295
- <div className="roadmaps-view__mobile-list" data-testid="roadmaps-view__mobile-list">
296
- <div className="roadmaps-view__mobile-list-header">
297
- <h2 className="roadmaps-view__mobile-list-title">Roadmaps</h2>
298
- {!showCreateForm && (
299
- <button
300
- className="roadmaps-view__mobile-add-btn"
301
- onClick={onCreate}
302
- title="Create roadmap"
303
- aria-label="Create roadmap"
304
- data-testid="mobile-create-roadmap-btn"
305
- >
306
- <Plus size={18} />
307
- </button>
308
- )}
309
- </div>
310
-
311
- {showCreateForm && (
312
- <div className="roadmaps-view__mobile-create-form">
313
- <CreateRoadmapForm onSave={onSaveCreate} onCancel={onCancelCreate} />
314
- </div>
315
- )}
316
-
317
- {roadmaps.length === 0 && !showCreateForm ? (
318
- <div className="roadmaps-view__mobile-empty">
319
- <p>No roadmaps yet.</p>
320
- <button className="btn btn-primary btn-sm" onClick={onCreate}>
321
- <Plus size={14} />
322
- <span>Create Roadmap</span>
323
- </button>
324
- </div>
325
- ) : (
326
- <div className="roadmaps-view__mobile-list-items">
327
- {roadmaps.map((roadmap) => (
328
- <div
329
- key={roadmap.id}
330
- className={`roadmaps-view__mobile-item${roadmap.id === selectedRoadmapId ? " roadmaps-view__mobile-item--active" : ""}`}
331
- onClick={() => onSelect(roadmap.id)}
332
- role="button"
333
- tabIndex={0}
334
- onKeyDown={(e) => {
335
- if (e.key === "Enter") {
336
- onSelect(roadmap.id);
337
- }
338
- }}
339
- data-testid={`mobile-roadmap-item-${roadmap.id}`}
340
- >
341
- <div className="roadmaps-view__mobile-item-content">
342
- <span className="roadmaps-view__mobile-item-title">{roadmap.title}</span>
343
- {roadmap.description && (
344
- <span className="roadmaps-view__mobile-item-desc">{roadmap.description}</span>
345
- )}
346
- </div>
347
- <div className="roadmaps-view__mobile-item-actions">
348
- <button
349
- className="roadmaps-view__mobile-action-btn"
350
- onClick={(e) => {
351
- e.stopPropagation();
352
- onExport(roadmap);
353
- }}
354
- title="Export roadmap"
355
- aria-label="Export roadmap"
356
- data-testid={`mobile-roadmap-export-${roadmap.id}`}
357
- >
358
- <Download size={16} />
359
- </button>
360
- <button
361
- className="roadmaps-view__mobile-action-btn"
362
- onClick={(e) => {
363
- e.stopPropagation();
364
- onEdit(roadmap);
365
- }}
366
- title="Edit roadmap"
367
- aria-label="Edit roadmap"
368
- data-testid={`mobile-roadmap-edit-${roadmap.id}`}
369
- >
370
- <Pencil size={16} />
371
- </button>
372
- <button
373
- className="roadmaps-view__mobile-action-btn roadmaps-view__mobile-action-btn--danger"
374
- onClick={(e) => {
375
- e.stopPropagation();
376
- onDelete(roadmap.id);
377
- }}
378
- title="Delete roadmap"
379
- aria-label="Delete roadmap"
380
- data-testid={`mobile-roadmap-delete-${roadmap.id}`}
381
- >
382
- <Trash2 size={16} />
383
- </button>
384
- </div>
385
- </div>
386
- ))}
387
- </div>
388
- )}
389
- </div>
390
- );
391
- }
392
-
393
- // ── Mobile Roadmap Header (shown when roadmap is selected) ────────────
394
-
395
- function MobileRoadmapHeader({
396
- roadmapTitle,
397
- onBack,
398
- onEdit,
399
- onDelete,
400
- onCreate,
401
- }: {
402
- roadmapTitle: string;
403
- onBack: () => void;
404
- onEdit: () => void;
405
- onDelete: () => void;
406
- onCreate: () => void;
407
- }) {
408
- return (
409
- <div className="roadmaps-view__mobile-header" data-testid="roadmaps-view__mobile-header">
410
- <button
411
- className="roadmaps-view__mobile-back-btn"
412
- onClick={onBack}
413
- title="Back to roadmap list"
414
- aria-label="Back to roadmap list"
415
- data-testid="mobile-back-btn"
416
- >
417
- <ArrowLeft size={20} />
418
- </button>
419
- <h2 className="roadmaps-view__mobile-header-title">{roadmapTitle}</h2>
420
- <div className="roadmaps-view__mobile-header-actions">
421
- <button
422
- className="roadmaps-view__mobile-action-btn"
423
- onClick={onCreate}
424
- title="Create roadmap"
425
- aria-label="Create roadmap"
426
- data-testid="mobile-header-create-btn"
427
- >
428
- <Plus size={18} />
429
- </button>
430
- <button
431
- className="roadmaps-view__mobile-action-btn"
432
- onClick={onEdit}
433
- title="Edit roadmap"
434
- aria-label="Edit roadmap"
435
- data-testid="mobile-header-edit-btn"
436
- >
437
- <Pencil size={18} />
438
- </button>
439
- <button
440
- className="roadmaps-view__mobile-action-btn roadmaps-view__mobile-action-btn--danger"
441
- onClick={onDelete}
442
- title="Delete roadmap"
443
- aria-label="Delete roadmap"
444
- data-testid="mobile-header-delete-btn"
445
- >
446
- <Trash2 size={18} />
447
- </button>
448
- </div>
449
- </div>
450
- );
451
- }
452
-
453
- // ── Milestone Card ───────────────────────────────────────────────────
454
-
455
- function MilestoneCard({
456
- milestone,
457
- features,
458
- onEditMilestone,
459
- onDeleteMilestone,
460
- onAddFeature,
461
- onEditFeature,
462
- onDeleteFeature,
463
- milestoneEdit,
464
- onMilestoneEditChange,
465
- onMilestoneEditFieldChange,
466
- onCancelMilestoneEdit,
467
- onSaveMilestoneEdit,
468
- featureEdit,
469
- onFeatureEditChange,
470
- onStartFeatureEdit: _onStartFeatureEdit,
471
- onCancelFeatureEdit,
472
- onSaveFeatureEdit,
473
- projectId: _projectId,
474
- addToast: _addToast,
475
- // Milestone drag-and-drop props
476
- isMilestoneDragging,
477
- isMilestoneDropTarget,
478
- milestoneDropPosition,
479
- onMilestoneDragStart,
480
- onMilestoneDragEnd,
481
- onMilestoneDragOver,
482
- onMilestoneDrop,
483
- onMilestoneDragLeave,
484
- // Feature drag-and-drop props
485
- isFeatureDragging,
486
- isFeatureDropTarget,
487
- featureDropIndex,
488
- onFeatureDragStart,
489
- onFeatureDragEnd,
490
- onFeatureDragOver,
491
- onFeatureDrop,
492
- onFeatureDragLeave,
493
- onFeatureDropOnMilestone,
494
- // Feature suggestion props
495
- featureSuggestions,
496
- isGeneratingFeatureSuggestions,
497
- onGenerateFeatureSuggestions,
498
- onAcceptFeatureSuggestion,
499
- onAcceptAllFeatureSuggestions,
500
- onUpdateFeatureSuggestionDraft,
501
- onClearFeatureSuggestions,
502
- }: {
503
- milestone: RoadmapMilestone;
504
- features: RoadmapFeature[];
505
- onEditMilestone: () => void;
506
- onDeleteMilestone: () => void;
507
- onAddFeature: () => void;
508
- onEditFeature: (featureId: string) => void;
509
- onDeleteFeature: (featureId: string) => void;
510
- milestoneEdit: MilestoneInlineEditState | null;
511
- onMilestoneEditChange: (value: string) => void;
512
- onMilestoneEditFieldChange: (field: "title" | "description") => void;
513
- onCancelMilestoneEdit: () => void;
514
- onSaveMilestoneEdit: (updates: RoadmapMilestoneUpdateInput) => void;
515
- featureEdit: FeatureInlineEditState | null;
516
- onFeatureEditChange: (value: string) => void;
517
- onStartFeatureEdit: (featureId: string, currentTitle: string, currentDescription?: string) => void;
518
- onCancelFeatureEdit: () => void;
519
- onSaveFeatureEdit: (updates: RoadmapFeatureUpdateInput) => void;
520
- projectId?: string;
521
- addToast: (message: string, type?: ToastType) => void;
522
- // Milestone drag-and-drop props
523
- isMilestoneDragging: boolean;
524
- isMilestoneDropTarget: boolean;
525
- milestoneDropPosition: "before" | "after" | null;
526
- onMilestoneDragStart: (milestoneId: string) => void;
527
- onMilestoneDragEnd: () => void;
528
- onMilestoneDragOver: (milestoneId: string) => void;
529
- onMilestoneDrop: (milestoneId: string) => void;
530
- onMilestoneDragLeave: (e: React.DragEvent) => void;
531
- // Feature drag-and-drop props
532
- isFeatureDragging: (featureId: string) => boolean;
533
- isFeatureDropTarget: boolean;
534
- featureDropIndex: number | null;
535
- onFeatureDragStart: (featureId: string, milestoneId: string) => void;
536
- onFeatureDragEnd: () => void;
537
- onFeatureDragOver: (featureId: string, position: "before" | "after") => void;
538
- onFeatureDrop: (featureId: string, targetIndex: number) => void;
539
- onFeatureDragLeave: (e: React.DragEvent) => void;
540
- onFeatureDropOnMilestone: () => void;
541
- // Feature suggestion props
542
- featureSuggestions?: FeatureSuggestion[];
543
- isGeneratingFeatureSuggestions?: boolean;
544
- onGenerateFeatureSuggestions?: () => void;
545
- onUpdateFeatureSuggestionDraft?: (milestoneId: string, draftId: string, patch: SuggestionDraftPatch) => void;
546
- onAcceptFeatureSuggestion?: (milestoneId: string, draftId: string) => void;
547
- onAcceptAllFeatureSuggestions?: () => void;
548
- onClearFeatureSuggestions?: () => void;
549
- }) {
550
- const isEditingMilestone = milestoneEdit?.milestoneId === milestone.id;
551
-
552
- const handleMilestoneTitleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
553
- if (e.key === "Enter") {
554
- e.preventDefault();
555
- if (milestoneEdit) {
556
- onSaveMilestoneEdit({ title: milestoneEdit.value });
557
- }
558
- } else if (e.key === "Escape") {
559
- onCancelMilestoneEdit();
560
- }
561
- };
562
-
563
- const handleMilestoneDescKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
564
- if (e.key === "Escape") {
565
- onCancelMilestoneEdit();
566
- }
567
- };
568
-
569
- // Build class names for drag states
570
- const milestoneClasses = [
571
- "roadmaps-view__milestone",
572
- isMilestoneDragging ? "roadmaps-view__milestone--dragging" : "",
573
- isMilestoneDropTarget ? "roadmaps-view__milestone--drop-target" : "",
574
- isMilestoneDropTarget && milestoneDropPosition === "before" ? "roadmaps-view__milestone--drop-before" : "",
575
- isMilestoneDropTarget && milestoneDropPosition === "after" ? "roadmaps-view__milestone--drop-after" : "",
576
- ].filter(Boolean).join(" ");
577
-
578
- // Build class names for feature list drop state
579
- const featureListClasses = [
580
- "roadmaps-view__feature-list",
581
- isFeatureDropTarget ? "roadmaps-view__feature-list--drop-target" : "",
582
- ].filter(Boolean).join(" ");
583
-
584
- return (
585
- <div
586
- className={milestoneClasses}
587
- draggable={!isEditingMilestone}
588
- onDragStart={(e) => {
589
- if (!isEditingMilestone) {
590
- onMilestoneDragStart(milestone.id);
591
- e.dataTransfer.setData("text/plain", `milestone:${milestone.id}`);
592
- e.dataTransfer.effectAllowed = "move";
593
- }
594
- }}
595
- onDragEnd={onMilestoneDragEnd}
596
- onDragOver={(e) => {
597
- // Only prevent default for milestone drops, not feature drops
598
- if (e.dataTransfer.types.includes("text/plain")) {
599
- const data = e.dataTransfer.types.includes("text/plain");
600
- if (data) {
601
- // This is a milestone drag
602
- e.preventDefault();
603
- e.dataTransfer.dropEffect = "move";
604
- onMilestoneDragOver(milestone.id);
605
- }
606
- }
607
- }}
608
- onDrop={(e) => {
609
- e.preventDefault();
610
- // Check if this is a feature drop or milestone drop
611
- const data = e.dataTransfer.getData("text/plain");
612
- if (data?.startsWith("feature:")) {
613
- // Feature drop - handled by child element
614
- } else {
615
- onMilestoneDrop(milestone.id);
616
- }
617
- }}
618
- onDragLeave={onMilestoneDragLeave}
619
- data-testid={`milestone-card-${milestone.id}`}
620
- >
621
- <div className="roadmaps-view__milestone-header">
622
- {isEditingMilestone && milestoneEdit ? (
623
- <div className="roadmaps-view__inline-edit">
624
- <div className="roadmaps-view__inline-edit-row">
625
- <span
626
- className="roadmaps-view__drag-handle"
627
- title="Drag to reorder"
628
- aria-label="Drag to reorder"
629
- data-testid={`milestone-drag-handle-${milestone.id}`}
630
- >
631
- <GripVertical size={14} />
632
- </span>
633
- <input
634
- type="text"
635
- className="roadmaps-view__inline-input"
636
- value={milestoneEdit.value}
637
- onChange={(e) => {
638
- onMilestoneEditFieldChange("title");
639
- onMilestoneEditChange(e.target.value);
640
- }}
641
- onKeyDown={handleMilestoneTitleKeyDown}
642
- placeholder="Milestone title"
643
- autoFocus
644
- data-testid={`milestone-title-input-${milestone.id}`}
645
- />
646
- <button
647
- className="roadmaps-view__icon-btn roadmaps-view__icon-btn--success"
648
- onClick={() => onSaveMilestoneEdit({ title: milestoneEdit.value })}
649
- aria-label="Save milestone title"
650
- title="Save"
651
- >
652
- <Check size={14} />
653
- </button>
654
- <button
655
- className="roadmaps-view__icon-btn"
656
- onClick={onCancelMilestoneEdit}
657
- aria-label="Cancel editing"
658
- title="Cancel"
659
- >
660
- <X size={14} />
661
- </button>
662
- </div>
663
- <textarea
664
- className="roadmaps-view__inline-textarea"
665
- value={milestoneEdit.field === "description" ? milestoneEdit.value : milestone.description || ""}
666
- onChange={(e) => {
667
- onMilestoneEditFieldChange("description");
668
- onMilestoneEditChange(e.target.value);
669
- }}
670
- onKeyDown={handleMilestoneDescKeyDown}
671
- placeholder="Milestone description (optional)"
672
- rows={2}
673
- data-testid={`milestone-desc-input-${milestone.id}`}
674
- />
675
- </div>
676
- ) : (
677
- <>
678
- <div className="roadmaps-view__milestone-title-row">
679
- <span
680
- className="roadmaps-view__drag-handle"
681
- title="Drag to reorder"
682
- aria-label="Drag to reorder"
683
- data-testid={`milestone-drag-handle-${milestone.id}`}
684
- >
685
- <GripVertical size={14} />
686
- </span>
687
- <h3 className="roadmaps-view__milestone-title">{milestone.title}</h3>
688
- <div className="roadmaps-view__milestone-actions">
689
- <button
690
- className="roadmaps-view__icon-btn"
691
- onClick={onEditMilestone}
692
- title="Edit milestone"
693
- aria-label="Edit milestone"
694
- data-testid={`milestone-edit-${milestone.id}`}
695
- >
696
- <Pencil size={14} />
697
- </button>
698
- <button
699
- className="roadmaps-view__icon-btn roadmaps-view__icon-btn--danger"
700
- onClick={onDeleteMilestone}
701
- title="Delete milestone"
702
- aria-label="Delete milestone"
703
- data-testid={`milestone-delete-${milestone.id}`}
704
- >
705
- <Trash2 size={14} />
706
- </button>
707
- </div>
708
- </div>
709
- {milestone.description && (
710
- <p className="roadmaps-view__milestone-desc">{milestone.description}</p>
711
- )}
712
- </>
713
- )}
714
- </div>
715
-
716
- <div className="roadmaps-view__milestone-actions-bar">
717
- <button
718
- className="roadmaps-view__add-feature-btn"
719
- onClick={onAddFeature}
720
- title="Add feature"
721
- aria-label="Add feature"
722
- data-testid={`add-feature-${milestone.id}`}
723
- >
724
- <Plus size={12} />
725
- <span>Add Feature</span>
726
- </button>
727
- <button
728
- className="roadmaps-view__suggest-btn"
729
- onClick={() => {
730
- // Generate feature suggestions for this milestone
731
- onGenerateFeatureSuggestions?.();
732
- }}
733
- disabled={isGeneratingFeatureSuggestions ?? false}
734
- title="Generate feature suggestions with AI"
735
- aria-label="Generate feature suggestions"
736
- data-testid={`generate-features-${milestone.id}`}
737
- >
738
- <Sparkles size={12} />
739
- <span>{isGeneratingFeatureSuggestions ? "Generating..." : "AI Suggestions"}</span>
740
- </button>
741
- </div>
742
-
743
- <div
744
- className={featureListClasses}
745
- onDragOver={(e) => {
746
- e.preventDefault();
747
- e.dataTransfer.dropEffect = "move";
748
- // Check if this is a feature being dragged
749
- const data = e.dataTransfer.getData("text/plain");
750
- if (data?.startsWith("feature:")) {
751
- onFeatureDropOnMilestone();
752
- }
753
- }}
754
- onDrop={(e) => {
755
- e.preventDefault();
756
- const data = e.dataTransfer.getData("text/plain");
757
- if (data?.startsWith("feature:")) {
758
- // Drop on empty area of feature list - append to end
759
- onFeatureDrop(data.split(":")[1], features.length);
760
- }
761
- }}
762
- onDragLeave={onFeatureDragLeave}
763
- >
764
- {features.length === 0 ? (
765
- <p className="roadmaps-view__empty-features">No features yet.</p>
766
- ) : (
767
- features.map((feature, index) => {
768
- const isEditingFeature = featureEdit?.featureId === feature.id;
769
- const isFeatureDraggingThis = isFeatureDragging(feature.id);
770
-
771
- const handleFeatureTitleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
772
- if (e.key === "Enter") {
773
- e.preventDefault();
774
- if (featureEdit) {
775
- onSaveFeatureEdit({ title: featureEdit.value });
776
- }
777
- } else if (e.key === "Escape") {
778
- onCancelFeatureEdit();
779
- }
780
- };
781
-
782
- // Build class names for feature drag states
783
- const featureClasses = [
784
- "roadmaps-view__feature-item",
785
- isFeatureDraggingThis ? "roadmaps-view__feature-item--dragging" : "",
786
- isFeatureDropTarget && featureDropIndex === index ? "roadmaps-view__feature-item--drop-before" : "",
787
- isFeatureDropTarget && featureDropIndex === index + 1 ? "roadmaps-view__feature-item--drop-after" : "",
788
- ].filter(Boolean).join(" ");
789
-
790
- return (
791
- <div
792
- key={feature.id}
793
- className={featureClasses}
794
- draggable={!isEditingFeature}
795
- onDragStart={(e) => {
796
- if (!isEditingFeature) {
797
- onFeatureDragStart(feature.id, milestone.id);
798
- e.dataTransfer.setData("text/plain", `feature:${feature.id}`);
799
- e.dataTransfer.effectAllowed = "move";
800
- }
801
- }}
802
- onDragEnd={onFeatureDragEnd}
803
- onDragOver={(e) => {
804
- e.preventDefault();
805
- e.stopPropagation();
806
- e.dataTransfer.dropEffect = "move";
807
- const data = e.dataTransfer.getData("text/plain");
808
- if (data?.startsWith("feature:")) {
809
- // Calculate position (before or after)
810
- const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
811
- const midY = rect.top + rect.height / 2;
812
- const position: "before" | "after" = e.clientY < midY ? "before" : "after";
813
- onFeatureDragOver(feature.id, position);
814
- }
815
- }}
816
- onDrop={(e) => {
817
- e.preventDefault();
818
- e.stopPropagation();
819
- const data = e.dataTransfer.getData("text/plain");
820
- if (data?.startsWith("feature:")) {
821
- const draggedFeatureId = data.split(":")[1];
822
- // Calculate target index
823
- const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
824
- const midY = rect.top + rect.height / 2;
825
- const position: "before" | "after" = e.clientY < midY ? "before" : "after";
826
- let targetIndex = index;
827
- if (position === "after") {
828
- targetIndex = index + 1;
829
- }
830
- onFeatureDrop(draggedFeatureId, targetIndex);
831
- }
832
- }}
833
- onDragLeave={onFeatureDragLeave}
834
- data-testid={`feature-item-${feature.id}`}
835
- >
836
- {isEditingFeature && featureEdit ? (
837
- <div className="roadmaps-view__inline-edit roadmaps-view__inline-edit--compact">
838
- <div className="roadmaps-view__inline-edit-row">
839
- <span
840
- className="roadmaps-view__drag-handle roadmaps-view__drag-handle--feature"
841
- title="Drag to reorder"
842
- aria-label="Drag to reorder"
843
- data-testid={`feature-drag-handle-${feature.id}`}
844
- >
845
- <GripVertical size={12} />
846
- </span>
847
- <input
848
- type="text"
849
- className="roadmaps-view__inline-input"
850
- value={featureEdit.value}
851
- onChange={(e) => onFeatureEditChange(e.target.value)}
852
- onKeyDown={handleFeatureTitleKeyDown}
853
- placeholder="Feature title"
854
- autoFocus
855
- data-testid={`feature-title-input-${feature.id}`}
856
- />
857
- <button
858
- className="roadmaps-view__icon-btn roadmaps-view__icon-btn--success"
859
- onClick={() => onSaveFeatureEdit({ title: featureEdit.value })}
860
- aria-label="Save feature title"
861
- title="Save"
862
- >
863
- <Check size={14} />
864
- </button>
865
- <button
866
- className="roadmaps-view__icon-btn"
867
- onClick={onCancelFeatureEdit}
868
- aria-label="Cancel editing"
869
- title="Cancel"
870
- >
871
- <X size={14} />
872
- </button>
873
- </div>
874
- </div>
875
- ) : (
876
- <>
877
- <span
878
- className="roadmaps-view__drag-handle roadmaps-view__drag-handle--feature"
879
- title="Drag to reorder"
880
- aria-label="Drag to reorder"
881
- data-testid={`feature-drag-handle-${feature.id}`}
882
- >
883
- <GripVertical size={12} />
884
- </span>
885
- <div className="roadmaps-view__feature-content">
886
- <span className="roadmaps-view__feature-title">{feature.title}</span>
887
- {feature.description && (
888
- <p className="roadmaps-view__feature-desc">{feature.description}</p>
889
- )}
890
- </div>
891
- <div className="roadmaps-view__feature-actions">
892
- <button
893
- className="roadmaps-view__icon-btn"
894
- onClick={() => onEditFeature(feature.id)}
895
- title="Edit feature"
896
- aria-label="Edit feature"
897
- data-testid={`feature-edit-${feature.id}`}
898
- >
899
- <Pencil size={12} />
900
- </button>
901
- <button
902
- className="roadmaps-view__icon-btn roadmaps-view__icon-btn--danger"
903
- onClick={() => onDeleteFeature(feature.id)}
904
- title="Delete feature"
905
- aria-label="Delete feature"
906
- data-testid={`feature-delete-${feature.id}`}
907
- >
908
- <Trash2 size={12} />
909
- </button>
910
- </div>
911
- </>
912
- )}
913
- </div>
914
- );
915
- })
916
- )}
917
-
918
- {/* Feature Suggestions Section */}
919
- {featureSuggestions && featureSuggestions.length > 0 && (
920
- <div className="roadmap-suggestion-section">
921
- <div className="roadmap-suggestion-header">
922
- <h4 className="roadmap-suggestion-title">AI Feature Suggestions</h4>
923
- <div className="roadmap-suggestion-actions">
924
- <button
925
- className="roadmap-suggestion-accept-all-btn"
926
- onClick={() => onAcceptAllFeatureSuggestions?.()}
927
- title="Accept all suggestions"
928
- aria-label="Accept all"
929
- data-testid={`accept-all-features-${milestone.id}`}
930
- >
931
- Accept All
932
- </button>
933
- <button
934
- className="roadmap-suggestion-clear-btn"
935
- onClick={() => onClearFeatureSuggestions?.()}
936
- title="Clear suggestions"
937
- aria-label="Clear"
938
- data-testid={`clear-features-${milestone.id}`}
939
- >
940
- Clear
941
- </button>
942
- </div>
943
- </div>
944
- <div className="roadmap-suggestion-list">
945
- {featureSuggestions.map((suggestion) => (
946
- <FeatureSuggestionCard
947
- key={suggestion.id}
948
- suggestion={suggestion}
949
- onUpdateDraft={(patch) => onUpdateFeatureSuggestionDraft?.(milestone.id, suggestion.id, patch)}
950
- onAccept={() => {
951
- onAcceptFeatureSuggestion?.(milestone.id, suggestion.id);
952
- }}
953
- testIdPrefix={`feature-suggestion-${milestone.id}`}
954
- />
955
- ))}
956
- </div>
957
- </div>
958
- )}
959
- </div>
960
- </div>
961
- );
962
- }
963
-
964
- // ── Feature Suggestion Card ───────────────────────────────────────────
965
-
966
- interface FeatureSuggestionCardProps {
967
- suggestion: FeatureSuggestion;
968
- onUpdateDraft: (patch: SuggestionDraftPatch) => void;
969
- onAccept: () => void;
970
- testIdPrefix: string;
971
- }
972
-
973
- function FeatureSuggestionCard({
974
- suggestion,
975
- onUpdateDraft,
976
- onAccept,
977
- testIdPrefix,
978
- }: FeatureSuggestionCardProps) {
979
- const [isEditing, setIsEditing] = useState(false);
980
- const [editTitle, setEditTitle] = useState(suggestion.title);
981
- const [editDescription, setEditDescription] = useState(suggestion.description || "");
982
-
983
- const handleStartEdit = () => {
984
- setEditTitle(suggestion.title);
985
- setEditDescription(suggestion.description || "");
986
- setIsEditing(true);
987
- };
988
-
989
- const handleSaveEdit = () => {
990
- onUpdateDraft({
991
- title: editTitle.trim(),
992
- description: editDescription.trim() || undefined,
993
- });
994
- setIsEditing(false);
995
- };
996
-
997
- const handleCancelEdit = () => {
998
- setEditTitle(suggestion.title);
999
- setEditDescription(suggestion.description || "");
1000
- setIsEditing(false);
1001
- };
1002
-
1003
- const handleAccept = () => {
1004
- if (!suggestion.title.trim()) {
1005
- return; // Don't accept empty titles
1006
- }
1007
- onAccept();
1008
- };
1009
-
1010
- const isValid = suggestion.title.trim().length > 0;
1011
-
1012
- if (isEditing) {
1013
- return (
1014
- <div className="roadmap-suggestion-card roadmap-suggestion-card--editing">
1015
- <div className="roadmap-suggestion-edit-form">
1016
- <input
1017
- type="text"
1018
- className="roadmap-suggestion-input"
1019
- value={editTitle}
1020
- onChange={(e) => setEditTitle(e.target.value)}
1021
- placeholder="Feature title"
1022
- autoFocus
1023
- data-testid={`${testIdPrefix}-${suggestion.id}-title-input`}
1024
- />
1025
- <textarea
1026
- className="roadmap-suggestion-textarea"
1027
- value={editDescription}
1028
- onChange={(e) => setEditDescription(e.target.value)}
1029
- placeholder="Description (optional)"
1030
- rows={2}
1031
- data-testid={`${testIdPrefix}-${suggestion.id}-desc-input`}
1032
- />
1033
- <div className="roadmap-suggestion-edit-actions">
1034
- <button
1035
- className="roadmap-suggestion-save-btn"
1036
- onClick={handleSaveEdit}
1037
- disabled={!editTitle.trim()}
1038
- title="Save"
1039
- data-testid={`${testIdPrefix}-${suggestion.id}-save`}
1040
- >
1041
- <Check size={12} />
1042
- </button>
1043
- <button
1044
- className="roadmap-suggestion-cancel-btn"
1045
- onClick={handleCancelEdit}
1046
- title="Cancel"
1047
- data-testid={`${testIdPrefix}-${suggestion.id}-cancel`}
1048
- >
1049
- <X size={12} />
1050
- </button>
1051
- </div>
1052
- </div>
1053
- </div>
1054
- );
1055
- }
1056
-
1057
- return (
1058
- <div
1059
- className="roadmap-suggestion-card"
1060
- data-testid={`${testIdPrefix}-${suggestion.id}`}
1061
- >
1062
- <div className="roadmap-suggestion-content">
1063
- <span className="roadmap-suggestion-card-title">{suggestion.title}</span>
1064
- {suggestion.description && (
1065
- <p className="roadmap-suggestion-card-desc">{suggestion.description}</p>
1066
- )}
1067
- </div>
1068
- <div className="roadmap-suggestion-card-actions">
1069
- <button
1070
- className="roadmap-suggestion-edit-btn"
1071
- onClick={handleStartEdit}
1072
- title="Edit suggestion"
1073
- aria-label="Edit"
1074
- data-testid={`${testIdPrefix}-${suggestion.id}-edit`}
1075
- >
1076
- <Pencil size={12} />
1077
- </button>
1078
- <button
1079
- className="roadmap-suggestion-accept-btn"
1080
- onClick={handleAccept}
1081
- disabled={!isValid}
1082
- title="Accept this suggestion"
1083
- aria-label="Accept"
1084
- data-testid={`${testIdPrefix}-${suggestion.id}-accept`}
1085
- >
1086
- <Check size={12} />
1087
- </button>
1088
- </div>
1089
- </div>
1090
- );
1091
- }
1092
-
1093
- // ── Milestone Suggestion Card ────────────────────────────────────────
1094
-
1095
- interface MilestoneSuggestionCardProps {
1096
- suggestion: MilestoneSuggestion;
1097
- onUpdateDraft: (patch: SuggestionDraftPatch) => void;
1098
- onAccept: () => void;
1099
- testIdPrefix: string;
1100
- }
1101
-
1102
- function MilestoneSuggestionCard({
1103
- suggestion,
1104
- onUpdateDraft,
1105
- onAccept,
1106
- testIdPrefix,
1107
- }: MilestoneSuggestionCardProps) {
1108
- const [isEditing, setIsEditing] = useState(false);
1109
- const [editTitle, setEditTitle] = useState(suggestion.title);
1110
- const [editDescription, setEditDescription] = useState(suggestion.description || "");
1111
-
1112
- const handleStartEdit = () => {
1113
- setEditTitle(suggestion.title);
1114
- setEditDescription(suggestion.description || "");
1115
- setIsEditing(true);
1116
- };
1117
-
1118
- const handleSaveEdit = () => {
1119
- onUpdateDraft({
1120
- title: editTitle.trim(),
1121
- description: editDescription.trim() || undefined,
1122
- });
1123
- setIsEditing(false);
1124
- };
1125
-
1126
- const handleCancelEdit = () => {
1127
- setEditTitle(suggestion.title);
1128
- setEditDescription(suggestion.description || "");
1129
- setIsEditing(false);
1130
- };
1131
-
1132
- const handleAccept = () => {
1133
- if (!suggestion.title.trim()) {
1134
- return; // Don't accept empty titles
1135
- }
1136
- onAccept();
1137
- };
1138
-
1139
- const isValid = suggestion.title.trim().length > 0;
1140
-
1141
- if (isEditing) {
1142
- return (
1143
- <div className="roadmap-suggestion-card roadmap-suggestion-card--editing">
1144
- <div className="roadmap-suggestion-edit-form">
1145
- <input
1146
- type="text"
1147
- className="roadmap-suggestion-input"
1148
- value={editTitle}
1149
- onChange={(e) => setEditTitle(e.target.value)}
1150
- placeholder="Milestone title"
1151
- autoFocus
1152
- data-testid={`${testIdPrefix}-${suggestion.id}-title-input`}
1153
- />
1154
- <textarea
1155
- className="roadmap-suggestion-textarea"
1156
- value={editDescription}
1157
- onChange={(e) => setEditDescription(e.target.value)}
1158
- placeholder="Description (optional)"
1159
- rows={2}
1160
- data-testid={`${testIdPrefix}-${suggestion.id}-desc-input`}
1161
- />
1162
- <div className="roadmap-suggestion-edit-actions">
1163
- <button
1164
- className="roadmap-suggestion-save-btn"
1165
- onClick={handleSaveEdit}
1166
- disabled={!editTitle.trim()}
1167
- title="Save"
1168
- data-testid={`${testIdPrefix}-${suggestion.id}-save`}
1169
- >
1170
- <Check size={12} />
1171
- </button>
1172
- <button
1173
- className="roadmap-suggestion-cancel-btn"
1174
- onClick={handleCancelEdit}
1175
- title="Cancel"
1176
- data-testid={`${testIdPrefix}-${suggestion.id}-cancel`}
1177
- >
1178
- <X size={12} />
1179
- </button>
1180
- </div>
1181
- </div>
1182
- </div>
1183
- );
1184
- }
1185
-
1186
- return (
1187
- <div
1188
- className="roadmap-suggestion-card"
1189
- data-testid={`${testIdPrefix}-${suggestion.id}`}
1190
- >
1191
- <div className="roadmap-suggestion-content">
1192
- <span className="roadmap-suggestion-card-title">{suggestion.title}</span>
1193
- {suggestion.description && (
1194
- <p className="roadmap-suggestion-card-desc">{suggestion.description}</p>
1195
- )}
1196
- </div>
1197
- <div className="roadmap-suggestion-card-actions">
1198
- <button
1199
- className="roadmap-suggestion-edit-btn"
1200
- onClick={handleStartEdit}
1201
- title="Edit suggestion"
1202
- aria-label="Edit"
1203
- data-testid={`${testIdPrefix}-${suggestion.id}-edit`}
1204
- >
1205
- <Pencil size={12} />
1206
- </button>
1207
- <button
1208
- className="roadmap-suggestion-accept-btn"
1209
- onClick={handleAccept}
1210
- disabled={!isValid}
1211
- title="Accept this suggestion"
1212
- aria-label="Accept"
1213
- data-testid={`${testIdPrefix}-${suggestion.id}-accept`}
1214
- >
1215
- <Check size={12} />
1216
- </button>
1217
- </div>
1218
- </div>
1219
- );
1220
- }
1221
-
1222
- // ── Create Form ───────────────────────────────────────────────────────
1223
-
1224
- function CreateRoadmapForm({
1225
- onSave,
1226
- onCancel,
1227
- }: {
1228
- onSave: (input: RoadmapCreateInput) => void;
1229
- onCancel: () => void;
1230
- }) {
1231
- const [title, setTitle] = useState("");
1232
- const [description, setDescription] = useState("");
1233
-
1234
- const handleSubmit = (e: React.FormEvent) => {
1235
- e.preventDefault();
1236
- if (!title.trim()) return;
1237
- onSave({ title: title.trim(), description: description.trim() || undefined });
1238
- };
1239
-
1240
- return (
1241
- <div className="roadmaps-view__create-form" data-testid="create-roadmap-form">
1242
- <form onSubmit={handleSubmit}>
1243
- <input
1244
- type="text"
1245
- className="roadmaps-view__inline-input"
1246
- value={title}
1247
- onChange={(e) => setTitle(e.target.value)}
1248
- placeholder="Roadmap title"
1249
- autoFocus
1250
- data-testid="create-roadmap-title"
1251
- />
1252
- <textarea
1253
- className="roadmaps-view__inline-textarea"
1254
- value={description}
1255
- onChange={(e) => setDescription(e.target.value)}
1256
- placeholder="Roadmap description (optional)"
1257
- rows={2}
1258
- data-testid="create-roadmap-description"
1259
- />
1260
- <div className="roadmaps-view__create-form-actions">
1261
- <button
1262
- type="submit"
1263
- className="roadmaps-view__btn roadmaps-view__btn--primary"
1264
- disabled={!title.trim()}
1265
- data-testid="create-roadmap-submit"
1266
- >
1267
- Create
1268
- </button>
1269
- <button
1270
- type="button"
1271
- className="roadmaps-view__btn"
1272
- onClick={onCancel}
1273
- data-testid="create-roadmap-cancel"
1274
- >
1275
- Cancel
1276
- </button>
1277
- </div>
1278
- </form>
1279
- </div>
1280
- );
1281
- }
1282
-
1283
- function CreateMilestoneForm({
1284
- onSave,
1285
- onCancel,
1286
- }: {
1287
- onSave: (input: RoadmapMilestoneCreateInput) => void;
1288
- onCancel: () => void;
1289
- }) {
1290
- const [title, setTitle] = useState("");
1291
- const [description, setDescription] = useState("");
1292
-
1293
- const handleSubmit = (e: React.FormEvent) => {
1294
- e.preventDefault();
1295
- if (!title.trim()) return;
1296
- onSave({ title: title.trim(), description: description.trim() || undefined });
1297
- };
1298
-
1299
- return (
1300
- <div className="roadmaps-view__create-form roadmaps-view__create-form--inline" data-testid="create-milestone-form">
1301
- <form onSubmit={handleSubmit} className="roadmaps-view__inline-form">
1302
- <input
1303
- type="text"
1304
- className="roadmaps-view__inline-input"
1305
- value={title}
1306
- onChange={(e) => setTitle(e.target.value)}
1307
- placeholder="Milestone title"
1308
- autoFocus
1309
- data-testid="create-milestone-title"
1310
- />
1311
- <textarea
1312
- className="roadmaps-view__inline-textarea"
1313
- value={description}
1314
- onChange={(e) => setDescription(e.target.value)}
1315
- placeholder="Description (optional)"
1316
- rows={1}
1317
- data-testid="create-milestone-description"
1318
- />
1319
- <div className="roadmaps-view__inline-form-actions">
1320
- <button
1321
- type="submit"
1322
- className="roadmaps-view__icon-btn roadmaps-view__icon-btn--success"
1323
- disabled={!title.trim()}
1324
- aria-label="Save milestone"
1325
- title="Save"
1326
- data-testid="create-milestone-submit"
1327
- >
1328
- <Check size={14} />
1329
- </button>
1330
- <button
1331
- type="button"
1332
- className="roadmaps-view__icon-btn"
1333
- onClick={onCancel}
1334
- aria-label="Cancel"
1335
- title="Cancel"
1336
- data-testid="create-milestone-cancel"
1337
- >
1338
- <X size={14} />
1339
- </button>
1340
- </div>
1341
- </form>
1342
- </div>
1343
- );
1344
- }
1345
-
1346
- function CreateFeatureForm({
1347
- onSave,
1348
- onCancel,
1349
- }: {
1350
- onSave: (input: RoadmapFeatureCreateInput) => void;
1351
- onCancel: () => void;
1352
- }) {
1353
- const [title, setTitle] = useState("");
1354
- const [description, setDescription] = useState("");
1355
-
1356
- const handleSubmit = (e: React.FormEvent) => {
1357
- e.preventDefault();
1358
- if (!title.trim()) return;
1359
- onSave({ title: title.trim(), description: description.trim() || undefined });
1360
- };
1361
-
1362
- return (
1363
- <div className="roadmaps-view__create-form roadmaps-view__create-form--inline" data-testid="create-feature-form">
1364
- <form onSubmit={handleSubmit} className="roadmaps-view__inline-form">
1365
- <input
1366
- type="text"
1367
- className="roadmaps-view__inline-input"
1368
- value={title}
1369
- onChange={(e) => setTitle(e.target.value)}
1370
- placeholder="Feature title"
1371
- autoFocus
1372
- data-testid="create-feature-title"
1373
- />
1374
- <textarea
1375
- className="roadmaps-view__inline-textarea"
1376
- value={description}
1377
- onChange={(e) => setDescription(e.target.value)}
1378
- placeholder="Description (optional)"
1379
- rows={1}
1380
- data-testid="create-feature-description"
1381
- />
1382
- <div className="roadmaps-view__inline-form-actions">
1383
- <button
1384
- type="submit"
1385
- className="roadmaps-view__icon-btn roadmaps-view__icon-btn--success"
1386
- disabled={!title.trim()}
1387
- aria-label="Save feature"
1388
- title="Save"
1389
- data-testid="create-feature-submit"
1390
- >
1391
- <Check size={14} />
1392
- </button>
1393
- <button
1394
- type="button"
1395
- className="roadmaps-view__icon-btn"
1396
- onClick={onCancel}
1397
- aria-label="Cancel"
1398
- title="Cancel"
1399
- data-testid="create-feature-cancel"
1400
- >
1401
- <X size={14} />
1402
- </button>
1403
- </div>
1404
- </form>
1405
- </div>
1406
- );
1407
- }
1408
-
1409
- // ── Main Component ────────────────────────────────────────────────────
1410
-
1411
- export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
1412
- const { confirm } = useConfirm();
1413
- const isMobile = useViewportMode() === "mobile";
1414
-
1415
- const {
1416
- roadmaps,
1417
- selectedRoadmapId,
1418
- selectedRoadmap,
1419
- milestones,
1420
- featuresByMilestoneId,
1421
- loading,
1422
- error,
1423
- createRoadmap,
1424
- updateRoadmap,
1425
- deleteRoadmap,
1426
- selectRoadmap,
1427
- createMilestone,
1428
- updateMilestone,
1429
- deleteMilestone,
1430
- createFeature,
1431
- updateFeature,
1432
- deleteFeature,
1433
- reorderMilestones,
1434
- reorderFeatures,
1435
- moveFeature,
1436
- milestoneSuggestions,
1437
- isGeneratingSuggestions,
1438
- generateMilestoneSuggestions,
1439
- updateMilestoneSuggestionDraft,
1440
- acceptMilestoneSuggestion,
1441
- acceptAllMilestoneSuggestions,
1442
- clearMilestoneSuggestions,
1443
- featureSuggestionsByMilestoneId,
1444
- isGeneratingFeatureSuggestions,
1445
- generateFeatureSuggestions,
1446
- updateFeatureSuggestionDraft,
1447
- acceptFeatureSuggestion,
1448
- acceptAllFeatureSuggestions,
1449
- clearFeatureSuggestions,
1450
- handoffPayload,
1451
- isFetchingHandoff,
1452
- handoffError,
1453
- fetchHandoff,
1454
- clearHandoff,
1455
- } = useRoadmaps({ projectId });
1456
-
1457
- // Handoff modal state
1458
- const [handoffModalOpen, setHandoffModalOpen] = useState(false);
1459
- const [handoffRoadmapId, setHandoffRoadmapId] = useState<string | null>(null);
1460
- const [handoffRoadmapTitle, setHandoffRoadmapTitle] = useState<string>("");
1461
-
1462
- // Goal prompt state for milestone suggestion generation
1463
- const [goalPrompt, setGoalPrompt] = useState("");
1464
-
1465
- // Mobile suggestion panel collapse state
1466
- const [showSuggestionPanel, setShowSuggestionPanel] = useState(false);
1467
-
1468
- // Reset suggestion panel when roadmap changes on mobile
1469
- const prevRoadmapIdRef = useRef<string | null>(null);
1470
- useEffect(() => {
1471
- if (prevRoadmapIdRef.current !== null && prevRoadmapIdRef.current !== selectedRoadmapId) {
1472
- setShowSuggestionPanel(false);
1473
- }
1474
- prevRoadmapIdRef.current = selectedRoadmapId;
1475
- }, [selectedRoadmapId]);
1476
-
1477
- // Inline edit states
1478
- const [roadmapEdit, setRoadmapEdit] = useState<InlineEditState>({
1479
- roadmapId: null,
1480
- field: null,
1481
- value: "",
1482
- });
1483
- const [milestoneEdit, setMilestoneEdit] = useState<MilestoneInlineEditState>({
1484
- milestoneId: null,
1485
- field: null,
1486
- value: "",
1487
- });
1488
- const [featureEdit, setFeatureEdit] = useState<FeatureInlineEditState>({
1489
- featureId: null,
1490
- field: null,
1491
- value: "",
1492
- });
1493
-
1494
- // Create form state
1495
- const [createForm, setCreateForm] = useState<CreateFormState>({
1496
- type: null,
1497
- parentId: undefined,
1498
- title: "",
1499
- description: "",
1500
- });
1501
-
1502
- // Mobile roadmap list create form state
1503
- const [mobileShowCreateForm, setMobileShowCreateForm] = useState(false);
1504
-
1505
- // Milestone drag-and-drop state
1506
- const [milestoneDrag, setMilestoneDrag] = useState<MilestoneDragState>({
1507
- draggingId: null,
1508
- dropTargetId: null,
1509
- dropPosition: null,
1510
- });
1511
-
1512
- // Milestone drag handlers
1513
- const handleMilestoneDragStart = useCallback((milestoneId: string) => {
1514
- setMilestoneDrag((prev) => ({
1515
- ...prev,
1516
- draggingId: milestoneId,
1517
- }));
1518
- }, []);
1519
-
1520
- const handleMilestoneDragEnd = useCallback(() => {
1521
- setMilestoneDrag({
1522
- draggingId: null,
1523
- dropTargetId: null,
1524
- dropPosition: null,
1525
- });
1526
- }, []);
1527
-
1528
- const handleMilestoneDragOver = useCallback((targetMilestoneId: string) => {
1529
- setMilestoneDrag((prev) => {
1530
- // Don't update if dragging over self
1531
- if (prev.draggingId === targetMilestoneId) {
1532
- return prev;
1533
- }
1534
- // Calculate drop position based on mouse position relative to target
1535
- // The position will be computed based on where the drop will happen
1536
- // For now, we just track the target
1537
- return {
1538
- ...prev,
1539
- dropTargetId: targetMilestoneId,
1540
- dropPosition: null, // Will be set in handleMilestoneDrop
1541
- };
1542
- });
1543
- }, []);
1544
-
1545
- // Feature drag-and-drop state
1546
- const [featureDrag, setFeatureDrag] = useState<FeatureDragState>({
1547
- draggingId: null,
1548
- draggingMilestoneId: null,
1549
- dropTargetMilestoneId: null,
1550
- dropTargetIndex: null,
1551
- dropPosition: null,
1552
- });
1553
-
1554
- // Feature drag handlers
1555
- const handleFeatureDragStart = useCallback((featureId: string, milestoneId: string) => {
1556
- setFeatureDrag((prev) => ({
1557
- ...prev,
1558
- draggingId: featureId,
1559
- draggingMilestoneId: milestoneId,
1560
- }));
1561
- }, []);
1562
-
1563
- const handleFeatureDragEnd = useCallback(() => {
1564
- setFeatureDrag({
1565
- draggingId: null,
1566
- draggingMilestoneId: null,
1567
- dropTargetMilestoneId: null,
1568
- dropTargetIndex: null,
1569
- dropPosition: null,
1570
- });
1571
- }, []);
1572
-
1573
- const handleFeatureDragOver = useCallback((targetFeatureId: string, position: "before" | "after") => {
1574
- setFeatureDrag((prev) => {
1575
- // Don't update if dragging over self
1576
- if (prev.draggingId === targetFeatureId) {
1577
- return prev;
1578
- }
1579
- // Find the target feature's index in its milestone
1580
- const targetFeatures = featuresByMilestoneId[prev.draggingMilestoneId || ""] || [];
1581
- const targetIndex = targetFeatures.findIndex((f) => f.id === targetFeatureId);
1582
-
1583
- let dropTargetIndex: number;
1584
- if (position === "before") {
1585
- dropTargetIndex = targetIndex;
1586
- } else {
1587
- dropTargetIndex = targetIndex + 1;
1588
- }
1589
-
1590
- return {
1591
- ...prev,
1592
- dropTargetMilestoneId: prev.draggingMilestoneId,
1593
- dropTargetIndex,
1594
- dropPosition: position,
1595
- };
1596
- });
1597
- }, [featuresByMilestoneId]);
1598
-
1599
- const handleFeatureDropOnMilestone = useCallback(() => {
1600
- setFeatureDrag((prev) => ({
1601
- ...prev,
1602
- dropTargetMilestoneId: prev.draggingMilestoneId,
1603
- // Append to end of feature list
1604
- dropTargetIndex: (featuresByMilestoneId[prev.draggingMilestoneId || ""] || []).length,
1605
- }));
1606
- }, [featuresByMilestoneId]);
1607
-
1608
- const handleFeatureDrop = useCallback(async (featureId: string, targetIndex: number) => {
1609
- const { draggingMilestoneId, dropTargetMilestoneId } = featureDrag;
1610
- if (!draggingMilestoneId) {
1611
- handleFeatureDragEnd();
1612
- return;
1613
- }
1614
-
1615
- // Determine the target milestone - use the drop target if available, otherwise the dragging milestone
1616
- const targetMilestoneId = dropTargetMilestoneId || draggingMilestoneId;
1617
-
1618
- // Get the source features
1619
- const sourceFeatures = featuresByMilestoneId[draggingMilestoneId] || [];
1620
-
1621
- // Find the feature being dragged
1622
- const featureBeingDragged = sourceFeatures.find((f) => f.id === featureId);
1623
- if (!featureBeingDragged) {
1624
- handleFeatureDragEnd();
1625
- return;
1626
- }
1627
-
1628
- // Check if this is a cross-milestone move
1629
- const isCrossMilestone = draggingMilestoneId !== targetMilestoneId;
1630
-
1631
- if (isCrossMilestone) {
1632
- // No-op check: if moving to same position in same milestone (shouldn't happen but safety check)
1633
- if (draggingMilestoneId === targetMilestoneId) {
1634
- handleFeatureDragEnd();
1635
- return;
1636
- }
1637
-
1638
- // Perform the move
1639
- try {
1640
- await moveFeature(featureId, targetMilestoneId, targetIndex, {
1641
- onError: (err) => {
1642
- addToast(`Failed to move feature: ${err.message}`, "error");
1643
- },
1644
- });
1645
- } catch {
1646
- // Error handled in callback
1647
- }
1648
- } else {
1649
- // Same-milestone reorder
1650
- const targetFeatures = [...sourceFeatures];
1651
- const fromIndex = targetFeatures.findIndex((f) => f.id === featureId);
1652
-
1653
- // Remove from current position and insert at target
1654
- targetFeatures.splice(fromIndex, 1);
1655
- targetFeatures.splice(targetIndex, 0, featureBeingDragged);
1656
-
1657
- // Compute new order of feature IDs
1658
- const orderedIds = targetFeatures.map((f) => f.id);
1659
-
1660
- // No-op check: if order is unchanged
1661
- const currentIds = sourceFeatures.map((f) => f.id);
1662
- if (orderedIds.join(",") === currentIds.join(",")) {
1663
- handleFeatureDragEnd();
1664
- return;
1665
- }
1666
-
1667
- // Perform the reorder
1668
- try {
1669
- await reorderFeatures(draggingMilestoneId, orderedIds, {
1670
- onError: (err) => {
1671
- addToast(`Failed to reorder features: ${err.message}`, "error");
1672
- },
1673
- });
1674
- } catch {
1675
- // Error handled in callback
1676
- }
1677
- }
1678
-
1679
- handleFeatureDragEnd();
1680
- }, [featureDrag, featuresByMilestoneId, reorderFeatures, moveFeature, addToast, handleFeatureDragEnd]);
1681
-
1682
- const handleFeatureDragLeave = useCallback((e: React.DragEvent) => {
1683
- // Only clear if leaving the element entirely
1684
- const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
1685
- const x = e.clientX;
1686
- const y = e.clientY;
1687
- if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
1688
- setFeatureDrag((prev) => ({
1689
- ...prev,
1690
- dropTargetMilestoneId: null,
1691
- dropTargetIndex: null,
1692
- dropPosition: null,
1693
- }));
1694
- }
1695
- }, []);
1696
-
1697
- // Check if a feature is being dragged
1698
- const isFeatureDragging = useCallback((featureId: string) => {
1699
- return featureDrag.draggingId === featureId;
1700
- }, [featureDrag.draggingId]);
1701
-
1702
- const handleMilestoneDrop = useCallback(async (targetMilestoneId: string) => {
1703
- const { draggingId } = milestoneDrag;
1704
- if (!draggingId || draggingId === targetMilestoneId) {
1705
- handleMilestoneDragEnd();
1706
- return;
1707
- }
1708
-
1709
- // Compute the new order
1710
- const currentOrder = milestones.map((m) => m.id);
1711
- const fromIndex = currentOrder.indexOf(draggingId);
1712
- const toIndex = currentOrder.indexOf(targetMilestoneId);
1713
-
1714
- if (fromIndex === -1 || toIndex === -1) {
1715
- handleMilestoneDragEnd();
1716
- return;
1717
- }
1718
-
1719
- // Compute the new order based on drop position
1720
- // The drop indicator shows where the item will be inserted
1721
- const newOrder = [...currentOrder];
1722
- newOrder.splice(fromIndex, 1);
1723
- newOrder.splice(toIndex, 0, draggingId);
1724
-
1725
- // No-op check: if the order is unchanged
1726
- if (newOrder.join(",") === currentOrder.join(",")) {
1727
- handleMilestoneDragEnd();
1728
- return;
1729
- }
1730
-
1731
- // Perform the reorder
1732
- try {
1733
- await reorderMilestones(selectedRoadmapId!, newOrder, {
1734
- onError: (err) => {
1735
- addToast(`Failed to reorder milestones: ${err.message}`, "error");
1736
- },
1737
- });
1738
- } catch {
1739
- // Error handled in callback
1740
- }
1741
-
1742
- handleMilestoneDragEnd();
1743
- }, [milestoneDrag, milestones, selectedRoadmapId, reorderMilestones, addToast, handleMilestoneDragEnd]);
1744
-
1745
- const handleMilestoneDragLeave = useCallback((e: React.DragEvent) => {
1746
- // Only clear if leaving the element entirely
1747
- const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
1748
- const x = e.clientX;
1749
- const y = e.clientY;
1750
- if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
1751
- setMilestoneDrag((prev) => ({
1752
- ...prev,
1753
- dropTargetId: null,
1754
- dropPosition: null,
1755
- }));
1756
- }
1757
- }, []);
1758
-
1759
- // Roadmap handlers
1760
- const handleStartRoadmapEdit = useCallback((roadmap: Roadmap) => {
1761
- selectRoadmap(roadmap.id);
1762
- setRoadmapEdit({
1763
- roadmapId: roadmap.id,
1764
- field: "title",
1765
- value: roadmap.title,
1766
- });
1767
- }, [selectRoadmap]);
1768
-
1769
- const handleCancelRoadmapEdit = useCallback(() => {
1770
- setRoadmapEdit({ roadmapId: null, field: null, value: "" });
1771
- }, []);
1772
-
1773
- const handleSaveRoadmapEdit = useCallback(
1774
- async (updates: RoadmapUpdateInput) => {
1775
- if (!roadmapEdit.roadmapId) return;
1776
- try {
1777
- await updateRoadmap(roadmapEdit.roadmapId, updates, {
1778
- onError: (err) => addToast(err.message, "error"),
1779
- });
1780
- handleCancelRoadmapEdit();
1781
- } catch {
1782
- // Error handled in callback
1783
- }
1784
- },
1785
- [roadmapEdit.roadmapId, updateRoadmap, handleCancelRoadmapEdit, addToast]
1786
- );
1787
-
1788
- const handleDeleteRoadmap = useCallback(
1789
- async (roadmapId: string) => {
1790
- const shouldDelete = await confirm({
1791
- title: "Delete Roadmap",
1792
- message: "Delete this roadmap? This cannot be undone.",
1793
- danger: true,
1794
- });
1795
- if (!shouldDelete) return;
1796
- try {
1797
- await deleteRoadmap(roadmapId, {
1798
- onError: (err) => addToast(err.message, "error"),
1799
- });
1800
- addToast("Roadmap deleted", "success");
1801
- } catch {
1802
- // Error handled in callback
1803
- }
1804
- },
1805
- [deleteRoadmap, addToast, confirm]
1806
- );
1807
-
1808
- // Handoff handlers
1809
- const handleOpenHandoffModal = useCallback((roadmapId: string, roadmapTitle: string) => {
1810
- setHandoffRoadmapId(roadmapId);
1811
- setHandoffRoadmapTitle(roadmapTitle);
1812
- setHandoffModalOpen(true);
1813
- // Clear any previous handoff data
1814
- clearHandoff();
1815
- }, [clearHandoff]);
1816
-
1817
- const handleCloseHandoffModal = useCallback(() => {
1818
- setHandoffModalOpen(false);
1819
- setHandoffRoadmapId(null);
1820
- setHandoffRoadmapTitle("");
1821
- clearHandoff();
1822
- }, [clearHandoff]);
1823
-
1824
- const handleFetchHandoff = useCallback(() => {
1825
- if (handoffRoadmapId) {
1826
- fetchHandoff(handoffRoadmapId, {
1827
- onError: (err) => addToast(`Failed to load handoff: ${err.message}`, "error"),
1828
- });
1829
- }
1830
- }, [handoffRoadmapId, fetchHandoff, addToast]);
1831
-
1832
- const handleCopyHandoffToClipboard = useCallback(() => {
1833
- if (handoffPayload) {
1834
- const data = JSON.stringify(handoffPayload, null, 2);
1835
- navigator.clipboard.writeText(data).then(() => {
1836
- addToast("Handoff data copied to clipboard", "success");
1837
- }).catch(() => {
1838
- addToast("Failed to copy to clipboard", "error");
1839
- });
1840
- }
1841
- }, [handoffPayload, addToast]);
1842
-
1843
- const handleCreateRoadmap = useCallback(
1844
- async (input: RoadmapCreateInput) => {
1845
- try {
1846
- await createRoadmap(input, {
1847
- onError: (err) => addToast(err.message, "error"),
1848
- });
1849
- setCreateForm({ type: null, parentId: undefined, title: "", description: "" });
1850
- addToast("Roadmap created", "success");
1851
- } catch {
1852
- // Error handled in callback
1853
- }
1854
- },
1855
- [createRoadmap, addToast]
1856
- );
1857
-
1858
- // Milestone handlers
1859
- const handleStartMilestoneEdit = useCallback((milestone: RoadmapMilestone) => {
1860
- setMilestoneEdit({
1861
- milestoneId: milestone.id,
1862
- field: "title",
1863
- value: milestone.title,
1864
- });
1865
- }, []);
1866
-
1867
- const handleMilestoneEditChange = useCallback((value: string) => {
1868
- setMilestoneEdit((previous) => ({ ...previous, value }));
1869
- }, []);
1870
-
1871
- const handleMilestoneEditFieldChange = useCallback((field: "title" | "description") => {
1872
- setMilestoneEdit((previous) => ({ ...previous, field }));
1873
- }, []);
1874
-
1875
- const handleCancelMilestoneEdit = useCallback(() => {
1876
- setMilestoneEdit({ milestoneId: null, field: null, value: "" });
1877
- }, []);
1878
-
1879
- const handleSaveMilestoneEdit = useCallback(
1880
- async (updates: RoadmapMilestoneUpdateInput) => {
1881
- if (!milestoneEdit.milestoneId) return;
1882
- try {
1883
- await updateMilestone(milestoneEdit.milestoneId, updates, {
1884
- onError: (err) => addToast(err.message, "error"),
1885
- });
1886
- handleCancelMilestoneEdit();
1887
- } catch {
1888
- // Error handled in callback
1889
- }
1890
- },
1891
- [milestoneEdit.milestoneId, updateMilestone, handleCancelMilestoneEdit, addToast]
1892
- );
1893
-
1894
- const handleDeleteMilestone = useCallback(
1895
- async (milestoneId: string) => {
1896
- const shouldDelete = await confirm({
1897
- title: "Delete Milestone",
1898
- message: "Delete this milestone and all its features?",
1899
- danger: true,
1900
- });
1901
- if (!shouldDelete) return;
1902
- try {
1903
- await deleteMilestone(milestoneId, {
1904
- onError: (err) => addToast(err.message, "error"),
1905
- });
1906
- addToast("Milestone deleted", "success");
1907
- } catch {
1908
- // Error handled in callback
1909
- }
1910
- },
1911
- [deleteMilestone, addToast, confirm]
1912
- );
1913
-
1914
- const handleCreateMilestone = useCallback(
1915
- async (input: RoadmapMilestoneCreateInput) => {
1916
- try {
1917
- await createMilestone(input, {
1918
- onError: (err) => addToast(err.message, "error"),
1919
- });
1920
- setCreateForm({ type: null, parentId: undefined, title: "", description: "" });
1921
- addToast("Milestone created", "success");
1922
- } catch {
1923
- // Error handled in callback
1924
- }
1925
- },
1926
- [createMilestone, addToast]
1927
- );
1928
-
1929
- // Feature handlers
1930
- const handleStartFeatureEdit = useCallback(
1931
- (featureId: string, currentTitle: string, _currentDescription?: string) => {
1932
- setFeatureEdit({
1933
- featureId,
1934
- field: "title",
1935
- value: currentTitle,
1936
- });
1937
- },
1938
- []
1939
- );
1940
-
1941
- const handleFeatureEditChange = useCallback((value: string) => {
1942
- setFeatureEdit((previous) => ({ ...previous, value }));
1943
- }, []);
1944
-
1945
- const handleCancelFeatureEdit = useCallback(() => {
1946
- setFeatureEdit({ featureId: null, field: null, value: "" });
1947
- }, []);
1948
-
1949
- const handleSaveFeatureEdit = useCallback(
1950
- async (updates: RoadmapFeatureUpdateInput) => {
1951
- if (!featureEdit.featureId) return;
1952
- try {
1953
- await updateFeature(featureEdit.featureId, updates, {
1954
- onError: (err) => addToast(err.message, "error"),
1955
- });
1956
- handleCancelFeatureEdit();
1957
- } catch {
1958
- // Error handled in callback
1959
- }
1960
- },
1961
- [featureEdit.featureId, updateFeature, handleCancelFeatureEdit, addToast]
1962
- );
1963
-
1964
- const handleDeleteFeature = useCallback(
1965
- async (featureId: string) => {
1966
- const shouldDelete = await confirm({
1967
- title: "Delete Feature",
1968
- message: "Delete this feature?",
1969
- danger: true,
1970
- });
1971
- if (!shouldDelete) return;
1972
- try {
1973
- await deleteFeature(featureId, {
1974
- onError: (err) => addToast(err.message, "error"),
1975
- });
1976
- addToast("Feature deleted", "success");
1977
- } catch {
1978
- // Error handled in callback
1979
- }
1980
- },
1981
- [deleteFeature, addToast, confirm]
1982
- );
1983
-
1984
- // Milestone suggestion handlers
1985
- const handleGenerateSuggestions = useCallback(
1986
- async () => {
1987
- if (!goalPrompt.trim()) return;
1988
- try {
1989
- await generateMilestoneSuggestions(goalPrompt, 5, {
1990
- onError: (err) => addToast(err.message, "error"),
1991
- });
1992
- } catch {
1993
- // Error handled in callback
1994
- }
1995
- },
1996
- [goalPrompt, generateMilestoneSuggestions, addToast]
1997
- );
1998
-
1999
- const handleAcceptSuggestion = useCallback(
2000
- async (draftId: string) => {
2001
- try {
2002
- await acceptMilestoneSuggestion(draftId, {
2003
- onError: (err) => addToast(err.message, "error"),
2004
- });
2005
- addToast("Milestone added", "success");
2006
- } catch {
2007
- // Error handled in callback
2008
- }
2009
- },
2010
- [acceptMilestoneSuggestion, addToast]
2011
- );
2012
-
2013
- const handleAcceptAllSuggestions = useCallback(
2014
- async () => {
2015
- try {
2016
- await acceptAllMilestoneSuggestions({
2017
- onError: (err) => addToast(err.message, "error"),
2018
- });
2019
- addToast(`${milestoneSuggestions.length} milestones added`, "success");
2020
- setGoalPrompt("");
2021
- } catch {
2022
- // Error handled in callback
2023
- }
2024
- },
2025
- [acceptAllMilestoneSuggestions, milestoneSuggestions.length, addToast]
2026
- );
2027
-
2028
- const handleClearSuggestions = useCallback(() => {
2029
- clearMilestoneSuggestions();
2030
- setGoalPrompt("");
2031
- }, [clearMilestoneSuggestions]);
2032
-
2033
- // Feature suggestion handlers
2034
- const handleGenerateFeatureSuggestions = useCallback(
2035
- async (milestoneId: string) => {
2036
- try {
2037
- await generateFeatureSuggestions(milestoneId, { count: 5 }, {
2038
- onError: (err) => addToast(err.message, "error"),
2039
- });
2040
- } catch {
2041
- // Error handled in callback
2042
- }
2043
- },
2044
- [generateFeatureSuggestions, addToast]
2045
- );
2046
-
2047
- const handleAcceptFeatureSuggestion = useCallback(
2048
- async (milestoneId: string, draftId: string) => {
2049
- try {
2050
- await acceptFeatureSuggestion(milestoneId, draftId, {
2051
- onError: (err) => addToast(err.message, "error"),
2052
- });
2053
- addToast("Feature added", "success");
2054
- } catch {
2055
- // Error handled in callback
2056
- }
2057
- },
2058
- [acceptFeatureSuggestion, addToast]
2059
- );
2060
-
2061
- const handleUpdateFeatureSuggestionDraft = useCallback(
2062
- (milestoneId: string, draftId: string, patch: SuggestionDraftPatch) => {
2063
- updateFeatureSuggestionDraft(milestoneId, draftId, patch);
2064
- },
2065
- [updateFeatureSuggestionDraft]
2066
- );
2067
-
2068
- const handleAcceptAllFeatureSuggestions = useCallback(
2069
- async (milestoneId: string) => {
2070
- const suggestions = featureSuggestionsByMilestoneId[milestoneId] || [];
2071
- try {
2072
- await acceptAllFeatureSuggestions(milestoneId, {
2073
- onError: (err) => addToast(err.message, "error"),
2074
- });
2075
- addToast(`${suggestions.length} features added`, "success");
2076
- } catch {
2077
- // Error handled in callback
2078
- }
2079
- },
2080
- [acceptAllFeatureSuggestions, featureSuggestionsByMilestoneId, addToast]
2081
- );
2082
-
2083
- const handleClearFeatureSuggestions = useCallback(
2084
- (milestoneId: string) => {
2085
- clearFeatureSuggestions(milestoneId);
2086
- },
2087
- [clearFeatureSuggestions]
2088
- );
2089
-
2090
- const handleCreateFeature = useCallback(
2091
- async (milestoneId: string, input: RoadmapFeatureCreateInput) => {
2092
- try {
2093
- await createFeature(milestoneId, input, {
2094
- onError: (err) => addToast(err.message, "error"),
2095
- });
2096
- setCreateForm({ type: null, parentId: undefined, title: "", description: "" });
2097
- addToast("Feature created", "success");
2098
- } catch {
2099
- // Error handled in callback
2100
- }
2101
- },
2102
- [createFeature, addToast]
2103
- );
2104
-
2105
- // Get the currently selected roadmap ID (handles both desktop and mobile)
2106
- const effectiveSelectedRoadmapId = selectedRoadmapId;
2107
-
2108
- if (loading && roadmaps.length === 0) {
2109
- return (
2110
- <div className="roadmaps-view roadmaps-view--loading">
2111
- <div className="roadmaps-view__loading-state">Loading roadmaps...</div>
2112
- </div>
2113
- );
2114
- }
2115
-
2116
- if (error && roadmaps.length === 0) {
2117
- return (
2118
- <div className="roadmaps-view roadmaps-view--error">
2119
- <div className="roadmaps-view__error-state">
2120
- <p>Failed to load roadmaps</p>
2121
- <p className="roadmaps-view__error-msg">{error.message}</p>
2122
- </div>
2123
- </div>
2124
- );
2125
- }
2126
-
2127
- return (
2128
- <div className="roadmaps-view">
2129
- {/* Mobile Roadmap List (shown when mobile and no roadmap selected) */}
2130
- {isMobile && !effectiveSelectedRoadmapId && (
2131
- <MobileRoadmapList
2132
- roadmaps={roadmaps}
2133
- selectedRoadmapId={effectiveSelectedRoadmapId}
2134
- onSelect={(id) => selectRoadmap(id)}
2135
- onCreate={() => setMobileShowCreateForm(true)}
2136
- onEdit={handleStartRoadmapEdit}
2137
- onDelete={handleDeleteRoadmap}
2138
- onExport={(roadmap) => handleOpenHandoffModal(roadmap.id, roadmap.title)}
2139
- showCreateForm={mobileShowCreateForm}
2140
- onCancelCreate={() => setMobileShowCreateForm(false)}
2141
- onSaveCreate={async (input) => {
2142
- await handleCreateRoadmap(input);
2143
- setMobileShowCreateForm(false);
2144
- }}
2145
- />
2146
- )}
2147
-
2148
- {/* Desktop sidebar (hidden on mobile) */}
2149
- {!isMobile && (
2150
- <aside className="roadmaps-view__sidebar" aria-label="Roadmaps">
2151
- <div className="roadmaps-view__sidebar-header">
2152
- <h2 className="roadmaps-view__sidebar-title">Roadmaps</h2>
2153
- <button
2154
- className="roadmaps-view__add-btn"
2155
- onClick={() => setCreateForm({ type: "roadmap", title: "", description: "" })}
2156
- title="Create roadmap"
2157
- aria-label="Create roadmap"
2158
- data-testid="create-roadmap-btn"
2159
- >
2160
- <Plus size={16} />
2161
- </button>
2162
- </div>
2163
-
2164
- {createForm.type === "roadmap" && (
2165
- <CreateRoadmapForm
2166
- onSave={handleCreateRoadmap}
2167
- onCancel={() => setCreateForm({ type: null, parentId: undefined, title: "", description: "" })}
2168
- />
2169
- )}
2170
-
2171
- <div className="roadmaps-view__sidebar-list">
2172
- {roadmaps.length === 0 ? (
2173
- <p className="roadmaps-view__empty-sidebar">No roadmaps yet. Click + to create one.</p>
2174
- ) : (
2175
- roadmaps.map((roadmap) => (
2176
- <RoadmapItem
2177
- key={roadmap.id}
2178
- roadmap={roadmap}
2179
- isSelected={roadmap.id === effectiveSelectedRoadmapId}
2180
- onSelect={() => selectRoadmap(roadmap.id)}
2181
- onEdit={() => handleStartRoadmapEdit(roadmap)}
2182
- onDelete={() => handleDeleteRoadmap(roadmap.id)}
2183
- onExport={() => handleOpenHandoffModal(roadmap.id, roadmap.title)}
2184
- />
2185
- ))
2186
- )}
2187
- </div>
2188
- </aside>
2189
- )}
2190
-
2191
- {/* Main content */}
2192
- <main className="roadmaps-view__main" aria-label="Roadmap content">
2193
- {/* Mobile header when roadmap is selected */}
2194
- {isMobile && effectiveSelectedRoadmapId && (
2195
- <MobileRoadmapHeader
2196
- roadmapTitle={selectedRoadmap?.title || "Untitled Roadmap"}
2197
- onBack={() => selectRoadmap(null)}
2198
- onEdit={() => {
2199
- if (selectedRoadmap) handleStartRoadmapEdit(selectedRoadmap);
2200
- }}
2201
- onDelete={() => handleDeleteRoadmap(effectiveSelectedRoadmapId)}
2202
- onCreate={() => setMobileShowCreateForm(true)}
2203
- />
2204
- )}
2205
-
2206
- {!effectiveSelectedRoadmapId ? (
2207
- <div className="roadmaps-view__empty-main">
2208
- <p>Select a roadmap from the sidebar to view its milestones.</p>
2209
- </div>
2210
- ) : (
2211
- <>
2212
- {/* Roadmap header */}
2213
- <div className="roadmaps-view__roadmap-header">
2214
- {roadmapEdit.roadmapId === effectiveSelectedRoadmapId ? (
2215
- <div className="roadmaps-view__inline-edit">
2216
- <div className="roadmaps-view__inline-edit-row">
2217
- <input
2218
- type="text"
2219
- className="roadmaps-view__inline-input roadmaps-view__inline-input--large"
2220
- value={roadmapEdit.value}
2221
- onChange={(e) =>
2222
- setRoadmapEdit((prev) => ({ ...prev, value: e.target.value }))
2223
- }
2224
- onKeyDown={(e) => {
2225
- if (e.key === "Enter") {
2226
- handleSaveRoadmapEdit({ title: roadmapEdit.value });
2227
- } else if (e.key === "Escape") {
2228
- handleCancelRoadmapEdit();
2229
- }
2230
- }}
2231
- placeholder="Roadmap title"
2232
- autoFocus
2233
- data-testid="roadmap-title-input"
2234
- />
2235
- <button
2236
- className="roadmaps-view__icon-btn roadmaps-view__icon-btn--success"
2237
- onClick={() => handleSaveRoadmapEdit({ title: roadmapEdit.value })}
2238
- aria-label="Save"
2239
- title="Save"
2240
- >
2241
- <Check size={16} />
2242
- </button>
2243
- <button
2244
- className="roadmaps-view__icon-btn"
2245
- onClick={handleCancelRoadmapEdit}
2246
- aria-label="Cancel"
2247
- title="Cancel"
2248
- >
2249
- <X size={16} />
2250
- </button>
2251
- </div>
2252
- </div>
2253
- ) : (
2254
- <>
2255
- <div className="roadmaps-view__roadmap-title-row">
2256
- <h1 className="roadmaps-view__roadmap-title">
2257
- {selectedRoadmap?.title || "Untitled Roadmap"}
2258
- </h1>
2259
- <div className="roadmaps-view__roadmap-actions">
2260
- <button
2261
- className="roadmaps-view__icon-btn"
2262
- onClick={() => {
2263
- if (selectedRoadmap) handleStartRoadmapEdit(selectedRoadmap);
2264
- }}
2265
- title="Edit roadmap"
2266
- aria-label="Edit roadmap"
2267
- data-testid="edit-roadmap-btn"
2268
- >
2269
- <Pencil size={16} />
2270
- </button>
2271
- <button
2272
- className="roadmaps-view__icon-btn roadmaps-view__icon-btn--danger"
2273
- onClick={() => handleDeleteRoadmap(effectiveSelectedRoadmapId)}
2274
- title="Delete roadmap"
2275
- aria-label="Delete roadmap"
2276
- data-testid="delete-roadmap-btn"
2277
- >
2278
- <Trash2 size={16} />
2279
- </button>
2280
- </div>
2281
- </div>
2282
- {selectedRoadmap?.description && (
2283
- <p className="roadmaps-view__roadmap-desc">{selectedRoadmap.description}</p>
2284
- )}
2285
- </>
2286
- )}
2287
- </div>
2288
-
2289
- {/* Milestone Suggestions Section */}
2290
- {isMobile ? (
2291
- showSuggestionPanel ? (
2292
- <div className="roadmap-suggestion-section">
2293
- <div className="roadmap-suggestion-header">
2294
- <h3 className="roadmap-suggestion-title">Generate Milestone Ideas</h3>
2295
- <button
2296
- className="roadmap-suggestion-collapse-btn"
2297
- onClick={() => setShowSuggestionPanel(false)}
2298
- aria-label="Collapse suggestion panel"
2299
- data-testid="collapse-suggestion-panel-btn"
2300
- >
2301
- <ChevronUp size={16} />
2302
- </button>
2303
- </div>
2304
- <div className="roadmap-suggestion-form">
2305
- <textarea
2306
- className="roadmap-suggestion-input"
2307
- value={goalPrompt}
2308
- onChange={(e) => setGoalPrompt(e.target.value)}
2309
- placeholder="Describe your roadmap goal (e.g., 'Build a user authentication system with OAuth, profiles, and admin dashboard')"
2310
- rows={2}
2311
- disabled={isGeneratingSuggestions || !selectedRoadmapId}
2312
- data-testid="goal-prompt-input"
2313
- autoFocus
2314
- />
2315
- <div className="roadmap-suggestion-actions">
2316
- <button
2317
- className="roadmap-suggestion-generate-btn"
2318
- onClick={handleGenerateSuggestions}
2319
- disabled={!goalPrompt.trim() || isGeneratingSuggestions || !selectedRoadmapId}
2320
- data-testid="generate-suggestions-btn"
2321
- >
2322
- {isGeneratingSuggestions ? "Generating..." : "Generate Milestones"}
2323
- </button>
2324
- {milestoneSuggestions.length > 0 && (
2325
- <>
2326
- <button
2327
- className="roadmap-suggestion-accept-all-btn"
2328
- onClick={handleAcceptAllSuggestions}
2329
- data-testid="accept-all-suggestions-btn"
2330
- >
2331
- Accept All ({milestoneSuggestions.length})
2332
- </button>
2333
- <button
2334
- className="roadmap-suggestion-clear-btn"
2335
- onClick={handleClearSuggestions}
2336
- title="Clear suggestions"
2337
- aria-label="Clear suggestions"
2338
- data-testid="clear-suggestions-btn"
2339
- >
2340
- <X size={14} />
2341
- </button>
2342
- </>
2343
- )}
2344
- </div>
2345
- </div>
2346
-
2347
- {/* Suggestion Cards */}
2348
- {milestoneSuggestions.length > 0 && (
2349
- <div className="roadmap-suggestion-list">
2350
- {milestoneSuggestions.map((suggestion) => (
2351
- <MilestoneSuggestionCard
2352
- key={suggestion.id}
2353
- suggestion={suggestion}
2354
- onUpdateDraft={(patch) => updateMilestoneSuggestionDraft(suggestion.id, patch)}
2355
- onAccept={() => handleAcceptSuggestion(suggestion.id)}
2356
- testIdPrefix="suggestion"
2357
- />
2358
- ))}
2359
- </div>
2360
- )}
2361
- </div>
2362
- ) : (
2363
- <div className="roadmap-suggestion-section">
2364
- <button
2365
- className="roadmap-suggestion-expand-btn"
2366
- onClick={() => setShowSuggestionPanel(true)}
2367
- disabled={!selectedRoadmapId}
2368
- data-testid="expand-suggestion-panel-btn"
2369
- >
2370
- <Sparkles size={16} />
2371
- Generate Milestone Ideas
2372
- </button>
2373
- </div>
2374
- )
2375
- ) : (
2376
- <div className="roadmap-suggestion-section">
2377
- <div className="roadmap-suggestion-header">
2378
- <h3 className="roadmap-suggestion-title">Generate Milestone Ideas</h3>
2379
- </div>
2380
- <div className="roadmap-suggestion-form">
2381
- <textarea
2382
- className="roadmap-suggestion-input"
2383
- value={goalPrompt}
2384
- onChange={(e) => setGoalPrompt(e.target.value)}
2385
- placeholder="Describe your roadmap goal (e.g., 'Build a user authentication system with OAuth, profiles, and admin dashboard')"
2386
- rows={2}
2387
- disabled={isGeneratingSuggestions || !selectedRoadmapId}
2388
- data-testid="goal-prompt-input"
2389
- />
2390
- <div className="roadmap-suggestion-actions">
2391
- <button
2392
- className="roadmap-suggestion-generate-btn"
2393
- onClick={handleGenerateSuggestions}
2394
- disabled={!goalPrompt.trim() || isGeneratingSuggestions || !selectedRoadmapId}
2395
- data-testid="generate-suggestions-btn"
2396
- >
2397
- {isGeneratingSuggestions ? "Generating..." : "Generate Milestones"}
2398
- </button>
2399
- {milestoneSuggestions.length > 0 && (
2400
- <>
2401
- <button
2402
- className="roadmap-suggestion-accept-all-btn"
2403
- onClick={handleAcceptAllSuggestions}
2404
- data-testid="accept-all-suggestions-btn"
2405
- >
2406
- Accept All ({milestoneSuggestions.length})
2407
- </button>
2408
- <button
2409
- className="roadmap-suggestion-clear-btn"
2410
- onClick={handleClearSuggestions}
2411
- title="Clear suggestions"
2412
- aria-label="Clear suggestions"
2413
- data-testid="clear-suggestions-btn"
2414
- >
2415
- <X size={14} />
2416
- </button>
2417
- </>
2418
- )}
2419
- </div>
2420
- </div>
2421
-
2422
- {/* Suggestion Cards */}
2423
- {milestoneSuggestions.length > 0 && (
2424
- <div className="roadmap-suggestion-list">
2425
- {milestoneSuggestions.map((suggestion) => (
2426
- <MilestoneSuggestionCard
2427
- key={suggestion.id}
2428
- suggestion={suggestion}
2429
- onUpdateDraft={(patch) => updateMilestoneSuggestionDraft(suggestion.id, patch)}
2430
- onAccept={() => handleAcceptSuggestion(suggestion.id)}
2431
- testIdPrefix="suggestion"
2432
- />
2433
- ))}
2434
- </div>
2435
- )}
2436
- </div>
2437
- )}
2438
-
2439
- {/* Milestone lanes */}
2440
- <div className="roadmaps-view__milestone-lanes">
2441
- {createForm.type === "milestone" && (
2442
- <CreateMilestoneForm
2443
- onSave={handleCreateMilestone}
2444
- onCancel={() => setCreateForm({ type: null, parentId: undefined, title: "", description: "" })}
2445
- />
2446
- )}
2447
-
2448
- {milestones.length === 0 && createForm.type !== "milestone" ? (
2449
- <div className="roadmaps-view__empty-milestones">
2450
- <p>This roadmap has no milestones.</p>
2451
- <button
2452
- className="roadmaps-view__add-milestone-btn"
2453
- onClick={() => setCreateForm({ type: "milestone", title: "", description: "" })}
2454
- data-testid="add-milestone-btn-empty"
2455
- >
2456
- <Plus size={14} />
2457
- <span>Add Milestone</span>
2458
- </button>
2459
- </div>
2460
- ) : (
2461
- <>
2462
- {createForm.type !== "milestone" && (
2463
- <button
2464
- className="roadmaps-view__add-milestone-fab"
2465
- onClick={() => setCreateForm({ type: "milestone", title: "", description: "" })}
2466
- data-testid="add-milestone-btn"
2467
- >
2468
- <Plus size={14} />
2469
- <span>Add Milestone</span>
2470
- </button>
2471
- )}
2472
- {milestones.map((milestone) => (
2473
- <MilestoneCard
2474
- key={milestone.id}
2475
- milestone={milestone}
2476
- features={featuresByMilestoneId[milestone.id] || []}
2477
- onEditMilestone={() => handleStartMilestoneEdit(milestone)}
2478
- onDeleteMilestone={() => handleDeleteMilestone(milestone.id)}
2479
- onAddFeature={() => setCreateForm({ type: "feature", parentId: milestone.id, title: "", description: "" })}
2480
- onEditFeature={(featureId) => {
2481
- const feature = featuresByMilestoneId[milestone.id]?.find((f) => f.id === featureId);
2482
- if (feature) {
2483
- handleStartFeatureEdit(featureId, feature.title, feature.description);
2484
- }
2485
- }}
2486
- onDeleteFeature={handleDeleteFeature}
2487
- milestoneEdit={milestoneEdit}
2488
- onMilestoneEditChange={handleMilestoneEditChange}
2489
- onMilestoneEditFieldChange={handleMilestoneEditFieldChange}
2490
- onCancelMilestoneEdit={handleCancelMilestoneEdit}
2491
- onSaveMilestoneEdit={handleSaveMilestoneEdit}
2492
- featureEdit={featureEdit}
2493
- onFeatureEditChange={handleFeatureEditChange}
2494
- onStartFeatureEdit={handleStartFeatureEdit}
2495
- onCancelFeatureEdit={handleCancelFeatureEdit}
2496
- onSaveFeatureEdit={handleSaveFeatureEdit}
2497
- projectId={projectId}
2498
- addToast={addToast}
2499
- // Milestone drag-and-drop props
2500
- isMilestoneDragging={milestoneDrag.draggingId === milestone.id}
2501
- isMilestoneDropTarget={milestoneDrag.dropTargetId === milestone.id}
2502
- milestoneDropPosition={milestoneDrag.dropTargetId === milestone.id ? milestoneDrag.dropPosition : null}
2503
- onMilestoneDragStart={handleMilestoneDragStart}
2504
- onMilestoneDragEnd={handleMilestoneDragEnd}
2505
- onMilestoneDragOver={handleMilestoneDragOver}
2506
- onMilestoneDrop={handleMilestoneDrop}
2507
- onMilestoneDragLeave={handleMilestoneDragLeave}
2508
- // Feature drag-and-drop props
2509
- isFeatureDragging={isFeatureDragging}
2510
- isFeatureDropTarget={featureDrag.dropTargetMilestoneId === milestone.id}
2511
- featureDropIndex={featureDrag.dropTargetMilestoneId === milestone.id ? featureDrag.dropTargetIndex : null}
2512
- onFeatureDragStart={handleFeatureDragStart}
2513
- onFeatureDragEnd={handleFeatureDragEnd}
2514
- onFeatureDragOver={handleFeatureDragOver}
2515
- onFeatureDrop={handleFeatureDrop}
2516
- onFeatureDragLeave={handleFeatureDragLeave}
2517
- onFeatureDropOnMilestone={handleFeatureDropOnMilestone}
2518
- // Feature suggestion props
2519
- featureSuggestions={featureSuggestionsByMilestoneId[milestone.id]}
2520
- isGeneratingFeatureSuggestions={isGeneratingFeatureSuggestions(milestone.id)}
2521
- onGenerateFeatureSuggestions={() => handleGenerateFeatureSuggestions(milestone.id)}
2522
- onAcceptFeatureSuggestion={(index) => handleAcceptFeatureSuggestion(milestone.id, index)}
2523
- onAcceptAllFeatureSuggestions={() => handleAcceptAllFeatureSuggestions(milestone.id)}
2524
- onUpdateFeatureSuggestionDraft={(milestoneId, draftId, patch) => handleUpdateFeatureSuggestionDraft(milestoneId, draftId, patch)}
2525
- onClearFeatureSuggestions={() => handleClearFeatureSuggestions(milestone.id)}
2526
- />
2527
- ))}
2528
- </>
2529
- )}
2530
- </div>
2531
- </>
2532
- )}
2533
- </main>
2534
-
2535
- {/* Feature create form overlay */}
2536
- {createForm.type === "feature" && createForm.parentId && (
2537
- <div className="roadmaps-view__feature-create-overlay">
2538
- <CreateFeatureForm
2539
- onSave={(input) => handleCreateFeature(createForm.parentId!, input)}
2540
- onCancel={() => setCreateForm({ type: null, parentId: undefined, title: "", description: "" })}
2541
- />
2542
- </div>
2543
- )}
2544
-
2545
- {/* Handoff export modal */}
2546
- <HandoffModal
2547
- isOpen={handoffModalOpen}
2548
- onClose={handleCloseHandoffModal}
2549
- roadmapId={handoffRoadmapId || ""}
2550
- roadmapTitle={handoffRoadmapTitle}
2551
- handoffPayload={handoffPayload}
2552
- isLoading={isFetchingHandoff}
2553
- error={handoffError}
2554
- onFetchHandoff={handleFetchHandoff}
2555
- onCopyToClipboard={handleCopyHandoffToClipboard}
2556
- />
2557
- </div>
2558
- );
2559
- }