feat(FN-1672): add AI feature suggestion UI to roadmaps

- Add dashboard API endpoints for roadmap feature suggestions
- Create useRoadmaps hook for fetching and managing roadmap data with suggestions
- Add feature suggestion UI to RoadmapsView with Accept All / Accept Individual buttons
- Render AI suggestions in MilestoneCard component
- Add CSS styling for suggestion buttons and interaction states
- Write comprehensive tests for useRoadmaps hook and RoadmapsView component
- Update dashboard guide documentation with feature suggestion usage
This commit is contained in:
Fusion
2026-04-15 12:50:08 -07:00
committed by gsxdsm
parent 533f627ea4
commit 48173275b1
11 changed files with 2097 additions and 6 deletions

View File

@@ -246,6 +246,29 @@ Roadmaps View includes one-click AI-powered milestone generation to help you qui
- Suggestions are ephemeral (in-memory only) and don't persist to the database
- The generate button is disabled when no roadmap is selected or prompt is empty
### AI Feature Suggestions
Within each milestone, you can generate AI-powered feature suggestions to quickly add features.
**How to use:**
1. Open a roadmap and select it
2. Find the milestone you want to add features to
3. Click the **AI Suggestions** button in the milestone's action bar
4. Review the suggested features:
- Click the **check icon** on any suggestion to accept it as a feature
- Click **Accept All** to add all suggestions as features (in order)
- Click **Clear** to discard all suggestions
**Key features:**
- Features are generated using AI based on the milestone's context
- Suggestions include existing features to avoid duplication
- Accepted features appear immediately in the milestone
- Features are created in the order displayed in the suggestion list
- Suggestion state is scoped to each milestone (no cross-milestone leakage)
- The AI Suggestions button shows "Generating..." while a request is in flight
### Empty States
- **No roadmaps**: "No roadmaps yet. Click + to create one."

View File

@@ -4303,5 +4303,73 @@ describe("Settings API wrappers", () => {
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("/api/roadmaps/features/RF-001/move?projectId=proj_xyz");
});
it("generateFeatureSuggestions sends POST with milestone ID", async () => {
const { generateFeatureSuggestions } = await import("./api");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve({ suggestions: [{ title: "Feature 1" }, { title: "Feature 2" }] }),
text: () => Promise.resolve(JSON.stringify({ suggestions: [{ title: "Feature 1" }, { title: "Feature 2" }] })),
} as unknown as Response);
const result = await generateFeatureSuggestions("RMS-001");
expect(result.suggestions).toHaveLength(2);
expect(result.suggestions[0].title).toBe("Feature 1");
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/roadmaps/milestones/RMS-001/suggestions/features");
});
it("generateFeatureSuggestions includes input parameters in body", async () => {
const { generateFeatureSuggestions } = await import("./api");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve({ suggestions: [] }),
text: () => Promise.resolve(JSON.stringify({ suggestions: [] })),
} as unknown as Response);
await generateFeatureSuggestions("RMS-001", { prompt: "Focus on auth", count: 3 });
const [, options] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse((options as RequestInit).body as string);
expect(body.prompt).toBe("Focus on auth");
expect(body.count).toBe(3);
});
it("generateFeatureSuggestions includes projectId when provided", async () => {
const { generateFeatureSuggestions } = await import("./api");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve({ suggestions: [] }),
text: () => Promise.resolve(JSON.stringify({ suggestions: [] })),
} as unknown as Response);
await generateFeatureSuggestions("RMS-001", undefined, "proj_abc");
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/roadmaps/milestones/RMS-001/suggestions/features");
expect(url).toContain("projectId=proj_abc");
});
});
});

View File

@@ -4592,6 +4592,40 @@ export function generateMilestoneSuggestions(
);
}
/** Response type for feature suggestions */
export interface FeatureSuggestionsResponse {
suggestions: Array<{
title: string;
description?: string;
}>;
}
/** Input for generating feature suggestions */
export interface GenerateFeatureSuggestionsInput {
/** Optional prompt to guide feature generation */
prompt?: string;
/** Number of features to generate (default 5, max 10) */
count?: number;
}
/** Generate feature suggestions for a milestone */
export function generateFeatureSuggestions(
milestoneId: string,
input?: GenerateFeatureSuggestionsInput,
projectId?: string
): Promise<FeatureSuggestionsResponse> {
return api<FeatureSuggestionsResponse>(
withProjectId(`/roadmaps/milestones/${encodeURIComponent(milestoneId)}/suggestions/features`, projectId),
{
method: "POST",
body: JSON.stringify({
...(input?.prompt !== undefined ? { prompt: input.prompt.trim() } : {}),
...(input?.count !== undefined ? { count: input.count } : {}),
}),
}
);
}
// ── AI Sessions (Background Tasks) ─────────────────────────────────────────
export interface AiSessionSummary {

View File

@@ -1,7 +1,7 @@
import { useState, useCallback } from "react";
import { Plus, Pencil, Trash2, Check, X, GripVertical } from "lucide-react";
import { Plus, Pencil, Trash2, Check, X, GripVertical, Sparkles } from "lucide-react";
import type { ToastType } from "../hooks/useToast";
import { useRoadmaps } from "../hooks/useRoadmaps";
import { useRoadmaps, type FeatureSuggestion } from "../hooks/useRoadmaps";
import type {
Roadmap,
RoadmapMilestone,
@@ -178,6 +178,13 @@ function MilestoneCard({
onFeatureDrop,
onFeatureDragLeave,
onFeatureDropOnMilestone,
// Feature suggestion props
featureSuggestions,
isGeneratingFeatureSuggestions,
onGenerateFeatureSuggestions,
onAcceptFeatureSuggestion,
onAcceptAllFeatureSuggestions,
onClearFeatureSuggestions,
}: {
milestone: RoadmapMilestone;
features: RoadmapFeature[];
@@ -215,6 +222,13 @@ function MilestoneCard({
onFeatureDrop: (featureId: string, targetIndex: number) => void;
onFeatureDragLeave: (e: React.DragEvent) => void;
onFeatureDropOnMilestone: () => void;
// Feature suggestion props
featureSuggestions?: FeatureSuggestion[];
isGeneratingFeatureSuggestions?: boolean;
onGenerateFeatureSuggestions?: () => void;
onAcceptFeatureSuggestion?: (index: number) => void;
onAcceptAllFeatureSuggestions?: () => void;
onClearFeatureSuggestions?: () => void;
}) {
const isEditingMilestone = milestoneEdit?.milestoneId === milestone.id;
@@ -391,6 +405,20 @@ function MilestoneCard({
<Plus size={12} />
<span>Add Feature</span>
</button>
<button
className="roadmaps-view__suggest-btn"
onClick={() => {
// Generate feature suggestions for this milestone
onGenerateFeatureSuggestions?.();
}}
disabled={isGeneratingFeatureSuggestions ?? false}
title="Generate feature suggestions with AI"
aria-label="Generate feature suggestions"
data-testid={`generate-features-${milestone.id}`}
>
<Sparkles size={12} />
<span>{isGeneratingFeatureSuggestions ? "Generating..." : "AI Suggestions"}</span>
</button>
</div>
<div
@@ -567,6 +595,60 @@ function MilestoneCard({
);
})
)}
{/* Feature Suggestions Section */}
{featureSuggestions && featureSuggestions.length > 0 && (
<div className="roadmap-suggestion-section">
<div className="roadmap-suggestion-header">
<h4 className="roadmap-suggestion-title">AI Feature Suggestions</h4>
<div className="roadmap-suggestion-actions">
<button
className="roadmap-suggestion-accept-all-btn"
onClick={() => onAcceptAllFeatureSuggestions?.()}
title="Accept all suggestions"
aria-label="Accept all"
data-testid={`accept-all-features-${milestone.id}`}
>
Accept All
</button>
<button
className="roadmap-suggestion-clear-btn"
onClick={() => onClearFeatureSuggestions?.()}
title="Clear suggestions"
aria-label="Clear"
data-testid={`clear-features-${milestone.id}`}
>
Clear
</button>
</div>
</div>
<div className="roadmap-suggestion-list">
{featureSuggestions.map((suggestion, index) => (
<div
key={`suggestion-${index}`}
className="roadmap-suggestion-card"
data-testid={`feature-suggestion-${milestone.id}-${index}`}
>
<div className="roadmap-suggestion-content">
<span className="roadmap-suggestion-card-title">{suggestion.title}</span>
{suggestion.description && (
<p className="roadmap-suggestion-card-desc">{suggestion.description}</p>
)}
</div>
<button
className="roadmap-suggestion-accept-btn"
onClick={() => onAcceptFeatureSuggestion?.(index)}
title="Accept this suggestion"
aria-label="Accept"
data-testid={`accept-feature-${milestone.id}-${index}`}
>
<Check size={12} />
</button>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
@@ -789,6 +871,12 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
acceptMilestoneSuggestion,
acceptAllMilestoneSuggestions,
clearMilestoneSuggestions,
featureSuggestionsByMilestoneId,
isGeneratingFeatureSuggestions,
generateFeatureSuggestions,
acceptFeatureSuggestion,
acceptAllFeatureSuggestions,
clearFeatureSuggestions,
} = useRoadmaps({ projectId });
// Goal prompt state for milestone suggestion generation
@@ -1290,6 +1378,56 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
setGoalPrompt("");
}, [clearMilestoneSuggestions]);
// Feature suggestion handlers
const handleGenerateFeatureSuggestions = useCallback(
async (milestoneId: string) => {
try {
await generateFeatureSuggestions(milestoneId, { count: 5 }, {
onError: (err) => addToast(err.message, "error"),
});
} catch {
// Error handled in callback
}
},
[generateFeatureSuggestions, addToast]
);
const handleAcceptFeatureSuggestion = useCallback(
async (milestoneId: string, index: number) => {
try {
await acceptFeatureSuggestion(milestoneId, index, {
onError: (err) => addToast(err.message, "error"),
});
addToast("Feature added", "success");
} catch {
// Error handled in callback
}
},
[acceptFeatureSuggestion, addToast]
);
const handleAcceptAllFeatureSuggestions = useCallback(
async (milestoneId: string) => {
const suggestions = featureSuggestionsByMilestoneId[milestoneId] || [];
try {
await acceptAllFeatureSuggestions(milestoneId, {
onError: (err) => addToast(err.message, "error"),
});
addToast(`${suggestions.length} features added`, "success");
} catch {
// Error handled in callback
}
},
[acceptAllFeatureSuggestions, featureSuggestionsByMilestoneId, addToast]
);
const handleClearFeatureSuggestions = useCallback(
(milestoneId: string) => {
clearFeatureSuggestions(milestoneId);
},
[clearFeatureSuggestions]
);
const handleCreateFeature = useCallback(
async (milestoneId: string, input: RoadmapFeatureCreateInput) => {
try {
@@ -1606,6 +1744,13 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
onFeatureDrop={handleFeatureDrop}
onFeatureDragLeave={handleFeatureDragLeave}
onFeatureDropOnMilestone={handleFeatureDropOnMilestone}
// Feature suggestion props
featureSuggestions={featureSuggestionsByMilestoneId[milestone.id]}
isGeneratingFeatureSuggestions={isGeneratingFeatureSuggestions(milestone.id)}
onGenerateFeatureSuggestions={() => handleGenerateFeatureSuggestions(milestone.id)}
onAcceptFeatureSuggestion={(index) => handleAcceptFeatureSuggestion(milestone.id, index)}
onAcceptAllFeatureSuggestions={() => handleAcceptAllFeatureSuggestions(milestone.id)}
onClearFeatureSuggestions={() => handleClearFeatureSuggestions(milestone.id)}
/>
))}
</>

View File

@@ -26,6 +26,7 @@ vi.mock("../../api", () => ({
reorderRoadmapMilestones: vi.fn(),
reorderRoadmapFeatures: vi.fn(),
moveRoadmapFeature: vi.fn(),
generateFeatureSuggestions: vi.fn(),
}));
// Mock lucide-react icons
@@ -38,6 +39,7 @@ vi.mock("lucide-react", () => ({
Check: (props: unknown) => <span data-testid="check-icon" {...props}>Check</span>,
X: (props: unknown) => <span data-testid="x-icon" {...props}>X</span>,
GripVertical: (props: unknown) => <span data-testid="grip-icon" {...props}>Grip</span>,
Sparkles: (props: unknown) => <span data-testid="sparkles-icon" {...props}>Sparkles</span>,
}));
const mockRoadmaps: Roadmap[] = [
@@ -506,4 +508,24 @@ describe("RoadmapsView", () => {
expect(screen.getByText("Select a roadmap from the sidebar to view its milestones.")).toBeInTheDocument();
});
describe("Feature suggestions", () => {
it("shows AI Suggestions button when roadmap is selected", async () => {
render(<RoadmapsView addToast={mockAddToast} />);
// Wait for roadmap to load
await waitFor(() => {
expect(screen.getByText("Q2 Roadmap")).toBeInTheDocument();
});
// Select roadmap
const roadmapItem = screen.getByTestId("roadmap-item-RM-001");
fireEvent.click(roadmapItem);
// Wait for milestone to load and button to appear
await waitFor(() => {
expect(screen.getByTestId("generate-features-RMS-001")).toBeInTheDocument();
}, { timeout: 3000 });
});
});
});

View File

@@ -19,6 +19,7 @@ vi.mock("../../api", () => ({
reorderRoadmapMilestones: vi.fn(),
reorderRoadmapFeatures: vi.fn(),
moveRoadmapFeature: vi.fn(),
generateFeatureSuggestions: vi.fn(),
}));
const mockRoadmaps = [
@@ -680,4 +681,313 @@ describe("useRoadmaps", () => {
).rejects.toThrow("Feature not found");
});
});
describe("Feature suggestions", () => {
it("generates feature suggestions for a milestone", async () => {
const mockSuggestions = [
{ title: "Feature 1", description: "Description 1" },
{ title: "Feature 2", description: "Description 2" },
];
(api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
suggestions: mockSuggestions,
});
const { result } = renderHook(() => useRoadmaps());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
result.current.selectRoadmap("RM-001");
await waitFor(() => {
expect(result.current.selectedRoadmapId).toBe("RM-001");
});
await result.current.generateFeatureSuggestions("RMS-001", { count: 5 });
await waitFor(() => {
expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toEqual(mockSuggestions);
});
expect(api.generateFeatureSuggestions).toHaveBeenCalledWith(
"RMS-001",
{ count: 5 },
undefined
);
});
it("generates feature suggestions with prompt", async () => {
(api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
suggestions: [{ title: "Auth Feature" }],
});
const { result } = renderHook(() => useRoadmaps());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await result.current.generateFeatureSuggestions("RMS-001", { prompt: "Focus on auth", count: 3 });
expect(api.generateFeatureSuggestions).toHaveBeenCalledWith(
"RMS-001",
{ prompt: "Focus on auth", count: 3 },
undefined
);
});
it("accepts a feature suggestion", async () => {
const mockFeature = {
id: "RF-NEW",
milestoneId: "RMS-001",
title: "New Feature",
description: "New description",
orderIndex: 0,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
(api.createRoadmapFeature as ReturnType<typeof vi.fn>).mockResolvedValue(mockFeature);
const { result } = renderHook(() => useRoadmaps());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
result.current.selectRoadmap("RM-001");
await waitFor(() => {
expect(result.current.selectedRoadmapId).toBe("RM-001");
});
// Manually set suggestions (simulating what generateFeatureSuggestions would do)
(api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
suggestions: [{ title: "New Feature", description: "New description" }],
});
await result.current.generateFeatureSuggestions("RMS-001");
await waitFor(() => {
expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toHaveLength(1);
});
// Accept the suggestion
await result.current.acceptFeatureSuggestion("RMS-001", 0);
// API should be called with the correct arguments
expect(api.createRoadmapFeature).toHaveBeenCalledWith(
"RMS-001",
{ title: "New Feature", description: "New description" },
undefined
);
});
it("accepts all feature suggestions sequentially", async () => {
const mockFeatures = [
{
id: "RF-NEW-1",
milestoneId: "RMS-001",
title: "Feature 1",
orderIndex: 0,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
{
id: "RF-NEW-2",
milestoneId: "RMS-001",
title: "Feature 2",
orderIndex: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
(api.createRoadmapFeature as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(mockFeatures[0])
.mockResolvedValueOnce(mockFeatures[1]);
const { result } = renderHook(() => useRoadmaps());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
result.current.selectRoadmap("RM-001");
await waitFor(() => {
expect(result.current.selectedRoadmapId).toBe("RM-001");
});
// Set suggestions
(api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
suggestions: [
{ title: "Feature 1" },
{ title: "Feature 2" },
],
});
await result.current.generateFeatureSuggestions("RMS-001");
await waitFor(() => {
expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toHaveLength(2);
});
// Capture suggestions before accepting
const suggestionsToAccept = [...(result.current.featureSuggestionsByMilestoneId["RMS-001"] || [])];
expect(suggestionsToAccept).toHaveLength(2);
// Accept all
await result.current.acceptAllFeatureSuggestions("RMS-001");
// Verify sequential calls (not parallel)
expect(api.createRoadmapFeature).toHaveBeenCalledTimes(2);
expect(api.createRoadmapFeature).toHaveBeenNthCalledWith(
1,
"RMS-001",
{ title: "Feature 1", description: undefined },
undefined
);
expect(api.createRoadmapFeature).toHaveBeenNthCalledWith(
2,
"RMS-001",
{ title: "Feature 2", description: undefined },
undefined
);
});
it("clears feature suggestions for a milestone", async () => {
const { result } = renderHook(() => useRoadmaps());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
result.current.selectRoadmap("RM-001");
await waitFor(() => {
expect(result.current.selectedRoadmapId).toBe("RM-001");
});
// Set suggestions
(api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
suggestions: [{ title: "Feature 1" }, { title: "Feature 2" }],
});
await result.current.generateFeatureSuggestions("RMS-001");
await waitFor(() => {
expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toHaveLength(2);
});
// Clear suggestions
result.current.clearFeatureSuggestions("RMS-001");
await waitFor(() => {
expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toBeUndefined();
});
});
it("is isolated per milestone", async () => {
const { result } = renderHook(() => useRoadmaps());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
result.current.selectRoadmap("RM-001");
await waitFor(() => {
expect(result.current.selectedRoadmapId).toBe("RM-001");
});
// Set suggestions for milestone 1
(api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
suggestions: [{ title: "MS1 Feature" }],
});
await result.current.generateFeatureSuggestions("RMS-001");
await waitFor(() => {
expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toHaveLength(1);
});
// Set suggestions for milestone 2
(api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
suggestions: [{ title: "MS2 Feature" }],
});
await result.current.generateFeatureSuggestions("RMS-002");
await waitFor(() => {
// Verify suggestions are isolated
expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toHaveLength(1);
expect(result.current.featureSuggestionsByMilestoneId["RMS-001"][0].title).toBe("MS1 Feature");
expect(result.current.featureSuggestionsByMilestoneId["RMS-002"]).toHaveLength(1);
expect(result.current.featureSuggestionsByMilestoneId["RMS-002"][0].title).toBe("MS2 Feature");
});
});
it("returns correct loading state for feature suggestions", async () => {
let resolveGenerate: (value: { suggestions: Array<{ title: string }> }) => void;
(api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockImplementation(() => {
return new Promise((resolve) => {
resolveGenerate = resolve;
});
});
const { result } = renderHook(() => useRoadmaps());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Start generating
const generatePromise = result.current.generateFeatureSuggestions("RMS-001");
// Wait for loading state to update
await waitFor(() => {
expect(result.current.isGeneratingFeatureSuggestions("RMS-001")).toBe(true);
});
// Complete generation
resolveGenerate!({ suggestions: [{ title: "Feature" }] });
await generatePromise;
await waitFor(() => {
// Check loading state is false
expect(result.current.isGeneratingFeatureSuggestions("RMS-001")).toBe(false);
});
});
it("clears feature suggestions when project changes", async () => {
const { result, rerender } = renderHook(
({ projectId }: { projectId?: string }) => useRoadmaps({ projectId }),
{ initialProps: { projectId: "proj-1" } }
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
result.current.selectRoadmap("RM-001");
await waitFor(() => {
expect(result.current.selectedRoadmapId).toBe("RM-001");
});
// Set suggestions
(api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
suggestions: [{ title: "Feature" }],
});
await result.current.generateFeatureSuggestions("RMS-001");
await waitFor(() => {
expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toHaveLength(1);
});
// Change project
rerender({ projectId: "proj-2" });
// Suggestions should be cleared
expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toBeUndefined();
});
});
});

View File

@@ -19,6 +19,12 @@ export interface MilestoneSuggestion {
description?: string;
}
/** A suggested feature from AI generation */
export interface FeatureSuggestion {
title: string;
description?: string;
}
export interface UseRoadmapsOptions {
/** When provided, fetches roadmaps for this project */
projectId?: string;
@@ -90,6 +96,20 @@ export interface UseRoadmapsResult {
/** Clear all pending milestone suggestions */
clearMilestoneSuggestions: () => void;
// Feature suggestion callbacks (ephemeral, scoped by milestone)
/** Pending feature suggestions by milestone ID (ephemeral, in-memory only) */
featureSuggestionsByMilestoneId: Record<string, FeatureSuggestion[]>;
/** Whether feature suggestions are being generated for a specific milestone */
isGeneratingFeatureSuggestions: (milestoneId: string) => boolean;
/** Generate feature suggestions for a specific milestone */
generateFeatureSuggestions: (milestoneId: string, input?: { prompt?: string; count?: number }, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
/** Accept a single feature suggestion and create it as a feature */
acceptFeatureSuggestion: (milestoneId: string, index: number, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
/** Accept all feature suggestions for a milestone (sequentially) */
acceptAllFeatureSuggestions: (milestoneId: string, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
/** Clear pending feature suggestions for a specific milestone */
clearFeatureSuggestions: (milestoneId: string) => void;
/** Refresh all roadmaps */
refresh: () => Promise<void>;
}
@@ -108,6 +128,17 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
const [milestoneSuggestions, setMilestoneSuggestions] = useState<MilestoneSuggestion[]>([]);
const [isGeneratingSuggestions, setIsGeneratingSuggestions] = useState(false);
// Ephemeral feature suggestion state keyed by milestone ID (in-memory only, not persisted)
const [featureSuggestionsByMilestoneId, setFeatureSuggestionsByMilestoneId] = useState<Record<string, FeatureSuggestion[]>>({});
const [generatingFeatureSuggestions, setGeneratingFeatureSuggestions] = useState<Record<string, boolean>>({});
// Refs for feature suggestion state
const featureSuggestionsByMilestoneIdRef = useRef(featureSuggestionsByMilestoneId);
const generatingFeatureSuggestionsRef = useRef(generatingFeatureSuggestions);
featureSuggestionsByMilestoneIdRef.current = featureSuggestionsByMilestoneId;
generatingFeatureSuggestionsRef.current = generatingFeatureSuggestions;
// Track previous projectId to detect changes
const previousProjectIdRef = useRef<string | undefined>(projectId);
// Project context version for stale-response protection
@@ -137,6 +168,8 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
// Clear ephemeral suggestion state
setMilestoneSuggestions([]);
setIsGeneratingSuggestions(false);
setFeatureSuggestionsByMilestoneId({});
setGeneratingFeatureSuggestions({});
}
}, [projectId]);
@@ -719,6 +752,195 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
setIsGeneratingSuggestions(false);
}, []);
// ── Feature Suggestion Actions (Ephemeral, Milestone-Scoped) ───────────────────────────────────
const isGeneratingFeatureSuggestions = useCallback((milestoneId: string): boolean => {
return generatingFeatureSuggestionsRef.current[milestoneId] ?? false;
}, []);
const generateFeatureSuggestions = useCallback(async (
milestoneId: string,
input?: { prompt?: string; count?: number },
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
// Capture project context version for stale-response protection
const contextVersionAtStart = projectContextVersionRef.current;
const requestProjectId = projectIdRef.current;
// Set loading state for this milestone
setGeneratingFeatureSuggestions((prev) => ({ ...prev, [milestoneId]: true }));
try {
const response = await api.generateFeatureSuggestions(
milestoneId,
input,
requestProjectId
);
// Check for stale response
if (projectContextVersionRef.current !== contextVersionAtStart) {
// Project context changed during fetch - discard response
return;
}
setFeatureSuggestionsByMilestoneId((prev) => ({
...prev,
[milestoneId]: response.suggestions,
}));
opts?.onSuccess?.();
} catch (err) {
// Check for stale response
if (projectContextVersionRef.current !== contextVersionAtStart) {
// Project context changed during fetch - discard error
return;
}
const error = err instanceof Error ? err : new Error("Failed to generate feature suggestions");
opts?.onError?.(error);
throw error;
} finally {
// Only clear loading state if context hasn't changed
if (projectContextVersionRef.current === contextVersionAtStart) {
setGeneratingFeatureSuggestions((prev) => ({ ...prev, [milestoneId]: false }));
}
}
}, []);
const acceptFeatureSuggestion = useCallback(async (
milestoneId: string,
index: number,
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
// Capture state for stale-response protection
const contextVersionAtStart = projectContextVersionRef.current;
const currentSuggestions = featureSuggestionsByMilestoneIdRef.current[milestoneId] || [];
if (index < 0 || index >= currentSuggestions.length) {
const error = new Error("Invalid suggestion index");
opts?.onError?.(error);
throw error;
}
const suggestion = currentSuggestions[index];
// Optimistic update: remove from suggestions immediately
setFeatureSuggestionsByMilestoneId((prev) => {
const milestoneSuggestions = prev[milestoneId] || [];
return {
...prev,
[milestoneId]: milestoneSuggestions.filter((_, i) => i !== index),
};
});
try {
await api.createRoadmapFeature(
milestoneId,
{ title: suggestion.title, description: suggestion.description },
projectIdRef.current
);
// Check for stale response
if (projectContextVersionRef.current !== contextVersionAtStart) {
// Project context changed - re-add to suggestions (optimistic rollback)
setFeatureSuggestionsByMilestoneId((prev) => {
const milestoneSuggestions = prev[milestoneId] || [];
const updated = [...milestoneSuggestions];
updated.splice(index, 0, suggestion);
return { ...prev, [milestoneId]: updated };
});
return;
}
// Refresh the roadmap to get the new feature
if (selectedRoadmapIdRef.current) {
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
}
opts?.onSuccess?.();
} catch (err) {
// Rollback: re-add to suggestions
setFeatureSuggestionsByMilestoneId((prev) => {
const milestoneSuggestions = prev[milestoneId] || [];
const updated = [...milestoneSuggestions];
updated.splice(index, 0, suggestion);
return { ...prev, [milestoneId]: updated };
});
const error = err instanceof Error ? err : new Error("Failed to accept suggestion");
opts?.onError?.(error);
throw error;
}
}, [fetchSelectedRoadmap]);
const acceptAllFeatureSuggestions = useCallback(async (
milestoneId: string,
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
// Capture current suggestions (they will be cleared sequentially)
const suggestionsToAccept = [...(featureSuggestionsByMilestoneIdRef.current[milestoneId] || [])];
if (suggestionsToAccept.length === 0) {
return;
}
// Capture state for stale-response protection
const contextVersionAtStart = projectContextVersionRef.current;
// Clear suggestions for this milestone immediately (optimistic)
setFeatureSuggestionsByMilestoneId((prev) => ({
...prev,
[milestoneId]: [],
}));
// Accept sequentially to preserve order
for (let i = 0; i < suggestionsToAccept.length; i++) {
// Check for stale response
if (projectContextVersionRef.current !== contextVersionAtStart) {
// Project context changed - stop accepting
break;
}
const suggestion = suggestionsToAccept[i];
try {
await api.createRoadmapFeature(
milestoneId,
{ title: suggestion.title, description: suggestion.description },
projectIdRef.current
);
} catch (err) {
// On error, stop accepting and report
const error = err instanceof Error ? err : new Error("Failed to accept all suggestions");
opts?.onError?.(error);
throw error;
}
}
// Check for stale response
if (projectContextVersionRef.current !== contextVersionAtStart) {
return;
}
// Refresh the roadmap to get all new features
if (selectedRoadmapIdRef.current) {
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
}
opts?.onSuccess?.();
}, [fetchSelectedRoadmap]);
const clearFeatureSuggestions = useCallback((milestoneId: string) => {
setFeatureSuggestionsByMilestoneId((prev) => {
const updated = { ...prev };
delete updated[milestoneId];
return updated;
});
setGeneratingFeatureSuggestions((prev) => {
const updated = { ...prev };
delete updated[milestoneId];
return updated;
});
}, []);
const refresh = useCallback(async () => {
await fetchRoadmaps();
if (selectedRoadmapIdRef.current) {
@@ -753,6 +975,12 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
acceptMilestoneSuggestion,
acceptAllMilestoneSuggestions,
clearMilestoneSuggestions,
featureSuggestionsByMilestoneId,
isGeneratingFeatureSuggestions,
generateFeatureSuggestions,
acceptFeatureSuggestion,
acceptAllFeatureSuggestions,
clearFeatureSuggestions,
refresh,
};
}

View File

@@ -30720,6 +30720,35 @@ html .column.drag-over * {
border-color: var(--text-muted);
}
/* AI Suggestion button */
.roadmaps-view__suggest-btn {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
background: var(--accent, #58a6ff);
color: #fff;
border: 1px solid var(--accent, #58a6ff);
border-radius: var(--radius-md);
font-size: 0.75rem;
cursor: pointer;
transition: background var(--transition-fast), transform var(--transition-fast);
}
.roadmaps-view__suggest-btn:hover {
background: var(--accent-hover, #4c94e6);
border-color: var(--accent-hover, #4c94e6);
}
.roadmaps-view__suggest-btn:active {
transform: scale(0.97);
}
.roadmaps-view__suggest-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Feature list */
.roadmaps-view__feature-list {
flex: 1;

View File

@@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
validateSuggestionInput,
generateMilestoneSuggestions,
validateFeatureSuggestionInput,
generateFeatureSuggestions,
ValidationError,
ParseError,
__resetSuggestionState,
@@ -616,4 +618,793 @@ describe("roadmap-suggestions", () => {
);
});
});
describe("validateFeatureSuggestionInput", () => {
it("accepts valid input with all fields", () => {
const input = {
prompt: "Focus on user authentication features",
count: 5,
};
expect(() => validateFeatureSuggestionInput(input)).not.toThrow();
});
it("accepts valid input without optional fields", () => {
const input = {};
expect(() => validateFeatureSuggestionInput(input)).not.toThrow();
});
it("accepts empty object", () => {
expect(() => validateFeatureSuggestionInput({})).not.toThrow();
});
it("accepts input with only count", () => {
const input = {
count: 3,
};
expect(() => validateFeatureSuggestionInput(input)).not.toThrow();
});
it("accepts count at minimum boundary (1)", () => {
const input = {
count: 1,
};
expect(() => validateFeatureSuggestionInput(input)).not.toThrow();
});
it("accepts count at maximum boundary (10)", () => {
const input = {
count: 10,
};
expect(() => validateFeatureSuggestionInput(input)).not.toThrow();
});
it("rejects null input", () => {
expect(() => validateFeatureSuggestionInput(null)).toThrow(ValidationError);
});
it("rejects non-object input", () => {
expect(() => validateFeatureSuggestionInput("string")).toThrow(ValidationError);
expect(() => validateFeatureSuggestionInput(123)).toThrow(ValidationError);
});
it("rejects array input", () => {
expect(() => validateFeatureSuggestionInput([])).toThrow(ValidationError);
expect(() => validateFeatureSuggestionInput([{ prompt: "test" }])).toThrow(ValidationError);
});
it("rejects non-string prompt", () => {
expect(() =>
validateFeatureSuggestionInput({ prompt: 123 })
).toThrow(ValidationError);
expect(() =>
validateFeatureSuggestionInput({ prompt: null })
).toThrow(ValidationError);
expect(() =>
validateFeatureSuggestionInput({ prompt: [] })
).toThrow(ValidationError);
});
it("rejects prompt exceeding max length", () => {
const longPrompt = "a".repeat(2001);
expect(() =>
validateFeatureSuggestionInput({ prompt: longPrompt })
).toThrow(ValidationError);
});
it("accepts prompt at exactly max length", () => {
const maxPrompt = "a".repeat(2000);
expect(() =>
validateFeatureSuggestionInput({ prompt: maxPrompt })
).not.toThrow();
});
it("rejects non-integer count", () => {
expect(() =>
validateFeatureSuggestionInput({ count: 3.5 })
).toThrow(ValidationError);
});
it("rejects count below minimum", () => {
expect(() =>
validateFeatureSuggestionInput({ count: 0 })
).toThrow(ValidationError);
expect(() =>
validateFeatureSuggestionInput({ count: -1 })
).toThrow(ValidationError);
});
it("rejects count above maximum", () => {
expect(() =>
validateFeatureSuggestionInput({ count: 11 })
).toThrow(ValidationError);
});
});
describe("generateFeatureSuggestions", () => {
const rootDir = "/test/project";
const baseContext = {
roadmapTitle: "E-Commerce Platform",
roadmapDescription: "Build a modern e-commerce platform",
milestoneTitle: "User Authentication",
milestoneDescription: "Implement user login and management",
existingFeatureTitles: [],
};
it("generates feature suggestions successfully", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "Login Form", "description": "Basic login form UI"}, {"title": "OAuth Integration", "description": "Support social login"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
const suggestions = await generateFeatureSuggestions(
baseContext,
5,
undefined,
rootDir
);
expect(suggestions).toHaveLength(2);
expect(suggestions[0]).toEqual({
title: "Login Form",
description: "Basic login form UI",
});
expect(suggestions[1]).toEqual({
title: "OAuth Integration",
description: "Support social login",
});
});
it("includes milestone context in system prompt", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "Feature", "description": "A feature"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await generateFeatureSuggestions(baseContext, 5, undefined, rootDir);
expect(mockCreateKbAgent).toHaveBeenCalledWith(
expect.objectContaining({
cwd: rootDir,
systemPrompt: expect.stringContaining("User Authentication"),
})
);
});
it("includes existing features in context when present", async () => {
const contextWithExistingFeatures = {
...baseContext,
existingFeatureTitles: ["Login Form", "Password Reset"],
};
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "New Feature", "description": "A new feature"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await generateFeatureSuggestions(
contextWithExistingFeatures,
5,
undefined,
rootDir
);
expect(mockCreateKbAgent).toHaveBeenCalledWith(
expect.objectContaining({
systemPrompt: expect.stringContaining("Login Form"),
})
);
});
it("includes optional prompt in user message", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "Feature", "description": "A feature"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await generateFeatureSuggestions(
baseContext,
5,
"Focus on security features",
rootDir
);
expect(mockSession.prompt).toHaveBeenCalledWith(
expect.stringContaining("Focus on security features")
);
});
it("respects the count parameter", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "Feature"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await generateFeatureSuggestions(baseContext, 3, undefined, rootDir);
expect(mockSession.prompt).toHaveBeenCalledWith(
expect.stringContaining("3 features")
);
});
it("disposes session after successful generation", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "Feature"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await generateFeatureSuggestions(baseContext, 5, undefined, rootDir);
expect(mockSession.dispose).toHaveBeenCalled();
});
it("throws when AI service is unavailable", async () => {
__setCreateKbAgent(undefined);
await expect(
generateFeatureSuggestions(baseContext, 5, undefined, rootDir)
).rejects.toThrow("AI service is not available");
});
it("throws when rootDir is missing", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await expect(
generateFeatureSuggestions(baseContext, 5, undefined)
).rejects.toThrow("rootDir is required");
});
it("handles markdown-wrapped JSON response", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '```json\n[\n {"title": "Feature", "description": "A feature"}\n]\n```',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
const suggestions = await generateFeatureSuggestions(
baseContext,
5,
undefined,
rootDir
);
expect(suggestions).toHaveLength(1);
expect(suggestions[0].title).toBe("Feature");
});
it("handles plain array response without markdown", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "Feature 1", "description": "First feature"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
const suggestions = await generateFeatureSuggestions(
baseContext,
5,
undefined,
rootDir
);
expect(suggestions).toHaveLength(1);
expect(suggestions[0].title).toBe("Feature 1");
});
it("handles suggestions without description", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[{"title": "No Description Feature"}, {"title": "With Description", "description": "Has description"}]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
const suggestions = await generateFeatureSuggestions(
baseContext,
5,
undefined,
rootDir
);
expect(suggestions).toHaveLength(2);
expect(suggestions[0]).toEqual({ title: "No Description Feature", description: undefined });
expect(suggestions[1]).toEqual({ title: "With Description", description: "Has description" });
});
it("limits suggestions to requested count", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "One"}, {"title": "Two"}, {"title": "Three"}, {"title": "Four"}, {"title": "Five"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
const suggestions = await generateFeatureSuggestions(
baseContext,
2,
undefined,
rootDir
);
expect(suggestions).toHaveLength(2);
expect(suggestions[0].title).toBe("One");
expect(suggestions[1].title).toBe("Two");
});
it("strips whitespace from titles and descriptions", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": " Trimmed Title ", "description": " With whitespace "}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
const suggestions = await generateFeatureSuggestions(
baseContext,
5,
undefined,
rootDir
);
expect(suggestions[0]).toEqual({
title: "Trimmed Title",
description: "With whitespace",
});
});
it("throws ParseError when AI returns no JSON", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: "Here are some features without JSON",
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await expect(
generateFeatureSuggestions(baseContext, 5, undefined, rootDir)
).rejects.toThrow(ParseError);
});
it("throws ParseError when JSON is not an array", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '{"title": "Not an array"}',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await expect(
generateFeatureSuggestions(baseContext, 5, undefined, rootDir)
).rejects.toThrow(ParseError);
});
it("throws ParseError when feature is missing title", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[{"description": "Missing title"}]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await expect(
generateFeatureSuggestions(baseContext, 5, undefined, rootDir)
).rejects.toThrow(ParseError);
});
it("supports model override parameters", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "Feature", "description": "A feature"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await generateFeatureSuggestions(
baseContext,
5,
undefined,
rootDir,
"openai",
"gpt-4o"
);
expect(mockCreateKbAgent).toHaveBeenCalledWith(
expect.objectContaining({
defaultProvider: "openai",
defaultModelId: "gpt-4o",
})
);
});
it("filters out invalid items and returns valid ones", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "Valid Feature"}, {"title": ""}, {"title": " "}, {"title": "Also Valid", "description": "Has desc"}, {"title": null}]\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
const suggestions = await generateFeatureSuggestions(
baseContext,
5,
undefined,
rootDir
);
// Should filter out empty/whitespace titles and return valid ones
expect(suggestions).toHaveLength(2);
expect(suggestions[0]).toEqual({ title: "Valid Feature", description: undefined });
expect(suggestions[1]).toEqual({ title: "Also Valid", description: "Has desc" });
});
it("throws ParseError when all items are invalid", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"description": "Only has desc"}, {"title": " "}, {"title": null}]\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await expect(
generateFeatureSuggestions(baseContext, 5, undefined, rootDir)
).rejects.toThrow(ParseError);
});
it("filters out non-object items", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "Valid"}, "string item", null, {"title": "Also Valid"}]\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
const suggestions = await generateFeatureSuggestions(
baseContext,
5,
undefined,
rootDir
);
// Should filter out non-object items and return valid ones
expect(suggestions).toHaveLength(2);
expect(suggestions[0]).toEqual({ title: "Valid", description: undefined });
expect(suggestions[1]).toEqual({ title: "Also Valid", description: undefined });
});
});
});

View File

@@ -301,18 +301,21 @@ function parseMilestoneSuggestions(text: string): MilestoneSuggestion[] {
throw new ParseError("AI response must be a JSON array of milestone suggestions");
}
// Validate and normalize each item
// Validate and normalize each item - filter invalid entries per spec
const suggestions: MilestoneSuggestion[] = [];
for (let i = 0; i < parsed.length; i++) {
const item = parsed[i];
// Skip items that are not objects
if (!item || typeof item !== "object") {
throw new ParseError(`Item ${i + 1} in AI response is invalid`);
continue;
}
const { title, description } = item as Record<string, unknown>;
// Skip entries with empty/whitespace-only titles per spec
if (typeof title !== "string" || !title.trim()) {
throw new ParseError(`Item ${i + 1} in AI response is missing a valid title`);
continue;
}
suggestions.push({
@@ -323,8 +326,9 @@ function parseMilestoneSuggestions(text: string): MilestoneSuggestion[] {
});
}
// If zero valid rows remain after filtering, return 500 error per spec
if (suggestions.length === 0) {
throw new ParseError("AI returned no milestone suggestions");
throw new ParseError("AI returned no valid milestone suggestions");
}
return suggestions;
@@ -478,6 +482,364 @@ export async function generateMilestoneSuggestions(
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// FEATURE SUGGESTION GENERATION
// ═══════════════════════════════════════════════════════════════════════════════
/** Input for generating feature suggestions within a milestone */
export interface GenerateFeatureSuggestionsInput {
/** Optional prompt to guide feature generation */
prompt?: string;
/** Number of features to generate (default 5, max 10) */
count?: number;
}
/** A suggested feature with title and optional description */
export interface FeatureSuggestion {
title: string;
description?: string;
}
/** Context about the milestone for feature generation */
export interface FeatureSuggestionContext {
/** Roadmap title */
roadmapTitle: string;
/** Roadmap description (optional) */
roadmapDescription?: string;
/** Milestone title */
milestoneTitle: string;
/** Milestone description (optional) */
milestoneDescription?: string;
/** Existing feature titles in this milestone */
existingFeatureTitles: string[];
}
/** System prompt for feature suggestion generation */
export const FEATURE_SUGGESTION_SYSTEM_PROMPT = `You are a feature planning assistant for a product roadmap system.
Your job is to suggest concrete, actionable features that belong within a specific milestone.
## Guidelines
1. **Be specific**: Feature titles should clearly describe what will be built (e.g., "User profile avatar upload", "API rate limiting")
2. **Actionable scope**: Each feature should be achievable in 1-2 weeks of focused work
3. **Add context**: Include a brief description explaining the feature's purpose and key aspects
4. **Avoid duplication**: Do NOT suggest features that are similar to existing ones already planned
5. **Order matters**: List features in the order they should be implemented within this milestone
## Context
The features should fit within the following milestone:
{MILESTONE_CONTEXT}
## Output Format
Respond with ONLY a valid JSON array of feature suggestions:
[
{
"title": "Feature Title",
"description": "Brief description of the feature (1-2 sentences)"
},
...
]
Do NOT include any markdown formatting, code fences, or additional text. Only output the JSON array.`;
/** Maximum length for feature generation prompt */
const MAX_FEATURE_PROMPT_LENGTH = 2000;
/**
* Validate the input for generating feature suggestions.
* Throws with a descriptive error message on validation failure.
*/
export function validateFeatureSuggestionInput(input: unknown): asserts input is GenerateFeatureSuggestionsInput {
if (!input || typeof input !== "object") {
throw new ValidationError("Request body must be an object");
}
// Arrays are objects in JS, but not valid input
if (Array.isArray(input)) {
throw new ValidationError("Request body must be an object, not an array");
}
const { prompt, count } = input as Record<string, unknown>;
// Validate prompt (optional)
if (prompt !== undefined) {
if (typeof prompt !== "string") {
throw new ValidationError("prompt must be a string");
}
if (prompt.length > MAX_FEATURE_PROMPT_LENGTH) {
throw new ValidationError(
`prompt exceeds maximum length of ${MAX_FEATURE_PROMPT_LENGTH} characters`
);
}
}
// Validate count (optional)
if (count !== undefined) {
if (typeof count !== "number" || !Number.isInteger(count)) {
throw new ValidationError("count must be an integer");
}
if (count < MIN_SUGGESTION_COUNT || count > MAX_SUGGESTION_COUNT) {
throw new ValidationError(
`count must be between ${MIN_SUGGESTION_COUNT} and ${MAX_SUGGESTION_COUNT}`
);
}
}
}
/**
* Build the milestone context string for the system prompt.
*/
function buildMilestoneContextString(context: FeatureSuggestionContext): string {
const lines: string[] = [];
lines.push(`Roadmap: ${context.roadmapTitle}`);
if (context.roadmapDescription) {
lines.push(`Description: ${context.roadmapDescription}`);
}
lines.push("");
lines.push(`Milestone: ${context.milestoneTitle}`);
if (context.milestoneDescription) {
lines.push(`Description: ${context.milestoneDescription}`);
}
if (context.existingFeatureTitles.length > 0) {
lines.push("");
lines.push("Existing features in this milestone:");
for (const title of context.existingFeatureTitles) {
lines.push(` - ${title}`);
}
}
return lines.join("\n");
}
/**
* Parse AI response for feature suggestions with robust extraction and recovery.
*/
function parseFeatureSuggestions(text: string): FeatureSuggestion[] {
const candidate = extractJsonCandidate(text);
if (!candidate) {
throw new ParseError("AI returned no valid JSON. Please try again.");
}
let parsed: unknown;
try {
parsed = JSON.parse(candidate);
} catch {
// Attempt repair for truncated/malformed JSON
try {
const repaired = repairJson(candidate);
parsed = JSON.parse(repaired);
} catch (repairErr) {
throw new ParseError(
`Failed to parse AI response: ${repairErr instanceof Error ? repairErr.message : "Unknown error"}. Please try again.`
);
}
}
// Validate structure: must be an array
if (!Array.isArray(parsed)) {
throw new ParseError("AI response must be a JSON array of feature suggestions");
}
// Validate and normalize each item - filter invalid entries per spec
const suggestions: FeatureSuggestion[] = [];
for (let i = 0; i < parsed.length; i++) {
const item = parsed[i];
// Skip items that are not objects
if (!item || typeof item !== "object") {
continue;
}
const { title, description } = item as Record<string, unknown>;
// Skip entries with empty/whitespace-only titles per spec
if (typeof title !== "string" || !title.trim()) {
continue;
}
suggestions.push({
title: title.trim(),
description: typeof description === "string" && description.trim()
? description.trim()
: undefined,
});
}
// If zero valid rows remain after filtering, return 500 error per spec
if (suggestions.length === 0) {
throw new ParseError("AI returned no valid feature suggestions");
}
return suggestions;
}
/**
* Generate feature suggestions for a specific milestone.
*
* @param context - Context about the milestone (roadmap info, milestone info, existing features)
* @param count - Number of suggestions to generate (default 5, max 10)
* @param prompt - Optional additional prompt to guide generation
* @param rootDir - Project root directory for AI context
* @param modelProvider - Optional AI model provider override
* @param modelId - Optional AI model ID override
* @returns Array of feature suggestions
*/
export async function generateFeatureSuggestions(
context: FeatureSuggestionContext,
count: number = DEFAULT_SUGGESTION_COUNT,
prompt?: string,
rootDir?: string,
modelProvider?: string,
modelId?: string,
): Promise<FeatureSuggestion[]> {
// Ensure engine is loaded before using createKbAgent
await initEngine();
if (!createKbAgent) {
throw new ServiceUnavailableError("AI service is not available");
}
if (!rootDir) {
throw new Error("rootDir is required for AI-powered suggestion generation");
}
// Build the milestone context string
const milestoneContextStr = buildMilestoneContextString(context);
// Build the system prompt with dynamic context
const systemPrompt = FEATURE_SUGGESTION_SYSTEM_PROMPT.replace(
"{MILESTONE_CONTEXT}",
milestoneContextStr
);
let agent: ReturnType<typeof createKbAgent> | undefined;
try {
// Create AI agent with feature suggestion system prompt
agent = await createKbAgent({
cwd: rootDir,
systemPrompt,
tools: "readonly",
...(modelProvider && modelId
? {
defaultProvider: modelProvider,
defaultModelId: modelId,
}
: {}),
onThinking: () => {
// Ignore thinking output for feature suggestions
},
onText: () => {
// Ignore incremental text
},
});
// Build the user message
let userMessage = `Please suggest ${count} features for the milestone described above.`;
if (prompt && prompt.trim()) {
userMessage += `\n\nAdditional guidance:\n${prompt.trim()}`;
}
// Get response from AI
await agent.session.prompt(userMessage);
// Extract response text from agent state
interface AgentMessage {
role: string;
content?: string | Array<{ type: string; text: string }>;
}
const lastMessage = (agent.session.state.messages as AgentMessage[])
.filter((m: AgentMessage) => m.role === "assistant")
.pop();
let responseText = "";
if (lastMessage?.content) {
if (typeof lastMessage.content === "string") {
responseText = lastMessage.content;
} else if (Array.isArray(lastMessage.content)) {
responseText = lastMessage.content
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
.map((c: { type: string; text: string }) => c.text)
.join("");
}
}
// Parse the JSON response with retry
let suggestions: FeatureSuggestion[] | undefined;
let lastError: Error | undefined;
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
try {
suggestions = parseFeatureSuggestions(responseText);
break;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (attempt < MAX_PARSE_RETRIES) {
// Retry: ask the AI to reformat as clean JSON
try {
await agent.session.prompt(
"Your previous response could not be parsed as JSON. " +
"Please respond with ONLY a JSON array of feature suggestions in this format: " +
'[{"title": "Feature Title", "description": "Brief description"}, ...]. ' +
"No markdown, no explanation, just the JSON array."
);
// Get the new response text
const retryMessage = (agent.session.state.messages as AgentMessage[])
.filter((m: AgentMessage) => m.role === "assistant")
.pop();
let retryText = "";
if (retryMessage?.content) {
if (typeof retryMessage.content === "string") {
retryText = retryMessage.content;
} else if (Array.isArray(retryMessage.content)) {
retryText = retryMessage.content
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
.map((c: { type: string; text: string }) => c.text)
.join("");
}
}
responseText = retryText;
} catch {
// Retry prompt itself failed — give up
break;
}
}
}
}
if (!suggestions) {
throw new ParseError(
`Failed to parse AI response after ${MAX_PARSE_RETRIES + 1} attempts: ${lastError?.message || "Unknown error"}`
);
}
// Limit to requested count
return suggestions.slice(0, count);
} finally {
// Always dispose the agent session
if (agent) {
try {
agent.session.dispose?.();
} catch {
// Ignore disposal errors
}
}
}
}
// ── Custom Errors ───────────────────────────────────────────────────────────
export class ValidationError extends Error {

View File

@@ -45,6 +45,8 @@ import { writeSSEEvent } from "./sse-buffer.js";
import {
generateMilestoneSuggestions,
validateSuggestionInput,
generateFeatureSuggestions,
validateFeatureSuggestionInput,
ValidationError as SuggestionValidationError,
ParseError as SuggestionParseError,
ServiceUnavailableError as SuggestionServiceUnavailableError,
@@ -2779,6 +2781,83 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// ── Roadmap Feature Suggestions ──────────────────────────────────────────
// Generate feature suggestions for a milestone
router.post("/roadmaps/milestones/:milestoneId/suggestions/features", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const roadmapStore = scopedStore.getRoadmapStore();
const { milestoneId } = req.params;
// Get the milestone to find the roadmap
const milestone = roadmapStore.getMilestone(milestoneId);
if (!milestone) {
throw notFound(`Milestone ${milestoneId} not found`);
}
// Get the roadmap for context
const roadmap = roadmapStore.getRoadmap(milestone.roadmapId);
if (!roadmap) {
throw notFound(`Roadmap ${milestone.roadmapId} not found`);
}
// Get existing features for this milestone
const existingFeatures = roadmapStore.listFeatures(milestoneId);
const existingFeatureTitles = existingFeatures.map((f) => f.title);
// Validate input
let input: { prompt?: string; count?: number };
try {
validateFeatureSuggestionInput(req.body);
input = req.body as { prompt?: string; count?: number };
} catch (err) {
if (err instanceof SuggestionValidationError) {
throw badRequest(err.message);
}
throw err;
}
// Build the context for feature suggestion
const context = {
roadmapTitle: roadmap.title,
roadmapDescription: roadmap.description,
milestoneTitle: milestone.title,
milestoneDescription: milestone.description,
existingFeatureTitles,
};
// Get project root directory for AI context
const rootDir = scopedStore.getRootDir();
// Generate suggestions
try {
const suggestions = await generateFeatureSuggestions(
context,
input.count,
input.prompt,
rootDir
);
res.json({ suggestions });
} catch (err) {
if (err instanceof SuggestionParseError) {
throw internalError(err.message);
}
if (err instanceof SuggestionServiceUnavailableError) {
res.status(503).json({ error: err.message });
return;
}
throw err;
}
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to generate feature suggestions");
}
});
// List all tasks
router.get("/tasks", async (req, res) => {
try {