feat(FN-1671): add AI milestone suggestion feature
- Add milestone suggestion generation backend contract with scoring algorithm - Add milestone suggestion hook actions (generate, accept, dismiss) in useRoadmaps - Add RoadmapsView UI with suggestion panel, cards, and accept/dismiss buttons - Document AI milestone suggestion feature in dashboard guide - Add roadmap-suggestions unit tests covering generation and UX flows
This commit is contained in:
@@ -4566,6 +4566,33 @@ export function moveRoadmapFeature(
|
||||
});
|
||||
}
|
||||
|
||||
/** Response from milestone suggestion generation */
|
||||
export interface MilestoneSuggestionsResponse {
|
||||
suggestions: Array<{
|
||||
title: string;
|
||||
description?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Generate milestone suggestions from a goal prompt */
|
||||
export function generateMilestoneSuggestions(
|
||||
roadmapId: string,
|
||||
goalPrompt: string,
|
||||
count?: number,
|
||||
projectId?: string
|
||||
): Promise<MilestoneSuggestionsResponse> {
|
||||
return api<MilestoneSuggestionsResponse>(
|
||||
withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}/suggestions/milestones`, projectId),
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
goalPrompt: goalPrompt.trim(),
|
||||
...(count !== undefined ? { count } : {}),
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ── AI Sessions (Background Tasks) ─────────────────────────────────────────
|
||||
|
||||
export interface AiSessionSummary {
|
||||
|
||||
@@ -783,8 +783,17 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
||||
reorderMilestones,
|
||||
reorderFeatures,
|
||||
moveFeature,
|
||||
milestoneSuggestions,
|
||||
isGeneratingSuggestions,
|
||||
generateMilestoneSuggestions,
|
||||
acceptMilestoneSuggestion,
|
||||
acceptAllMilestoneSuggestions,
|
||||
clearMilestoneSuggestions,
|
||||
} = useRoadmaps({ projectId });
|
||||
|
||||
// Goal prompt state for milestone suggestion generation
|
||||
const [goalPrompt, setGoalPrompt] = useState("");
|
||||
|
||||
// Inline edit states
|
||||
const [roadmapEdit, setRoadmapEdit] = useState<InlineEditState>({
|
||||
roadmapId: null,
|
||||
@@ -1232,6 +1241,55 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
||||
[deleteFeature, addToast]
|
||||
);
|
||||
|
||||
// Milestone suggestion handlers
|
||||
const handleGenerateSuggestions = useCallback(
|
||||
async () => {
|
||||
if (!goalPrompt.trim()) return;
|
||||
try {
|
||||
await generateMilestoneSuggestions(goalPrompt, 5, {
|
||||
onError: (err) => addToast(err.message, "error"),
|
||||
});
|
||||
} catch {
|
||||
// Error handled in callback
|
||||
}
|
||||
},
|
||||
[goalPrompt, generateMilestoneSuggestions, addToast]
|
||||
);
|
||||
|
||||
const handleAcceptSuggestion = useCallback(
|
||||
async (index: number) => {
|
||||
try {
|
||||
await acceptMilestoneSuggestion(index, {
|
||||
onError: (err) => addToast(err.message, "error"),
|
||||
});
|
||||
addToast("Milestone added", "success");
|
||||
} catch {
|
||||
// Error handled in callback
|
||||
}
|
||||
},
|
||||
[acceptMilestoneSuggestion, addToast]
|
||||
);
|
||||
|
||||
const handleAcceptAllSuggestions = useCallback(
|
||||
async () => {
|
||||
try {
|
||||
await acceptAllMilestoneSuggestions({
|
||||
onError: (err) => addToast(err.message, "error"),
|
||||
});
|
||||
addToast(`${milestoneSuggestions.length} milestones added`, "success");
|
||||
setGoalPrompt("");
|
||||
} catch {
|
||||
// Error handled in callback
|
||||
}
|
||||
},
|
||||
[acceptAllMilestoneSuggestions, milestoneSuggestions.length, addToast]
|
||||
);
|
||||
|
||||
const handleClearSuggestions = useCallback(() => {
|
||||
clearMilestoneSuggestions();
|
||||
setGoalPrompt("");
|
||||
}, [clearMilestoneSuggestions]);
|
||||
|
||||
const handleCreateFeature = useCallback(
|
||||
async (milestoneId: string, input: RoadmapFeatureCreateInput) => {
|
||||
try {
|
||||
@@ -1396,6 +1454,81 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Milestone Suggestions Section */}
|
||||
<div className="roadmap-suggestion-section">
|
||||
<div className="roadmap-suggestion-header">
|
||||
<h3 className="roadmap-suggestion-title">Generate Milestone Ideas</h3>
|
||||
</div>
|
||||
<div className="roadmap-suggestion-form">
|
||||
<textarea
|
||||
className="roadmap-suggestion-input"
|
||||
value={goalPrompt}
|
||||
onChange={(e) => setGoalPrompt(e.target.value)}
|
||||
placeholder="Describe your roadmap goal (e.g., 'Build a user authentication system with OAuth, profiles, and admin dashboard')"
|
||||
rows={2}
|
||||
disabled={isGeneratingSuggestions || !selectedRoadmapId}
|
||||
data-testid="goal-prompt-input"
|
||||
/>
|
||||
<div className="roadmap-suggestion-actions">
|
||||
<button
|
||||
className="roadmap-suggestion-generate-btn"
|
||||
onClick={handleGenerateSuggestions}
|
||||
disabled={!goalPrompt.trim() || isGeneratingSuggestions || !selectedRoadmapId}
|
||||
data-testid="generate-suggestions-btn"
|
||||
>
|
||||
{isGeneratingSuggestions ? "Generating..." : "Generate Milestones"}
|
||||
</button>
|
||||
{milestoneSuggestions.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
className="roadmap-suggestion-accept-all-btn"
|
||||
onClick={handleAcceptAllSuggestions}
|
||||
data-testid="accept-all-suggestions-btn"
|
||||
>
|
||||
Accept All ({milestoneSuggestions.length})
|
||||
</button>
|
||||
<button
|
||||
className="roadmap-suggestion-clear-btn"
|
||||
onClick={handleClearSuggestions}
|
||||
title="Clear suggestions"
|
||||
aria-label="Clear suggestions"
|
||||
data-testid="clear-suggestions-btn"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestion Cards */}
|
||||
{milestoneSuggestions.length > 0 && (
|
||||
<div className="roadmap-suggestion-list">
|
||||
{milestoneSuggestions.map((suggestion, index) => (
|
||||
<div key={index} className="roadmap-suggestion-card" data-testid={`suggestion-card-${index}`}>
|
||||
<div className="roadmap-suggestion-card-content">
|
||||
<span className="roadmap-suggestion-card-title">{suggestion.title}</span>
|
||||
{suggestion.description && (
|
||||
<span className="roadmap-suggestion-card-desc">{suggestion.description}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="roadmap-suggestion-card-actions">
|
||||
<button
|
||||
className="roadmap-suggestion-accept-btn"
|
||||
onClick={() => handleAcceptSuggestion(index)}
|
||||
title="Accept this milestone"
|
||||
aria-label="Accept this milestone"
|
||||
data-testid={`accept-suggestion-${index}`}
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Milestone lanes */}
|
||||
<div className="roadmaps-view__milestone-lanes">
|
||||
{createForm.type === "milestone" && (
|
||||
|
||||
@@ -13,6 +13,12 @@ import type {
|
||||
} from "@fusion/core";
|
||||
import * as api from "../api";
|
||||
|
||||
/** A suggested milestone from AI generation */
|
||||
export interface MilestoneSuggestion {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UseRoadmapsOptions {
|
||||
/** When provided, fetches roadmaps for this project */
|
||||
projectId?: string;
|
||||
@@ -70,6 +76,20 @@ export interface UseRoadmapsResult {
|
||||
/** Move a feature to a different milestone or position */
|
||||
moveFeature: (featureId: string, targetMilestoneId: string, targetIndex: number, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
||||
|
||||
// Milestone suggestion callbacks
|
||||
/** Current pending milestone suggestions (ephemeral, in-memory only) */
|
||||
milestoneSuggestions: MilestoneSuggestion[];
|
||||
/** Whether suggestions are currently being generated */
|
||||
isGeneratingSuggestions: boolean;
|
||||
/** Generate milestone suggestions from a goal prompt */
|
||||
generateMilestoneSuggestions: (goalPrompt: string, count?: number, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
||||
/** Accept a single milestone suggestion and create it as a milestone */
|
||||
acceptMilestoneSuggestion: (index: number, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
||||
/** Accept all milestone suggestions and create them as milestones (sequentially) */
|
||||
acceptAllMilestoneSuggestions: (opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
||||
/** Clear all pending milestone suggestions */
|
||||
clearMilestoneSuggestions: () => void;
|
||||
|
||||
/** Refresh all roadmaps */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
@@ -84,8 +104,14 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// Ephemeral milestone suggestion state (in-memory only, not persisted)
|
||||
const [milestoneSuggestions, setMilestoneSuggestions] = useState<MilestoneSuggestion[]>([]);
|
||||
const [isGeneratingSuggestions, setIsGeneratingSuggestions] = useState(false);
|
||||
|
||||
// Track previous projectId to detect changes
|
||||
const previousProjectIdRef = useRef<string | undefined>(projectId);
|
||||
// Project context version for stale-response protection
|
||||
const projectContextVersionRef = useRef(0);
|
||||
// Refs to access latest state in callbacks
|
||||
const roadmapsRef = useRef(roadmaps);
|
||||
const selectedRoadmapIdRef = useRef(selectedRoadmapId);
|
||||
@@ -99,14 +125,18 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
featuresByMilestoneIdRef.current = featuresByMilestoneId;
|
||||
projectIdRef.current = projectId;
|
||||
|
||||
// Clear selection when project changes
|
||||
// Clear selection and suggestions when project changes
|
||||
useEffect(() => {
|
||||
if (previousProjectIdRef.current !== projectId) {
|
||||
previousProjectIdRef.current = projectId;
|
||||
projectContextVersionRef.current++;
|
||||
setSelectedRoadmapId(null);
|
||||
setSelectedRoadmap(null);
|
||||
setMilestones([]);
|
||||
setFeaturesByMilestoneId({});
|
||||
// Clear ephemeral suggestion state
|
||||
setMilestoneSuggestions([]);
|
||||
setIsGeneratingSuggestions(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
@@ -507,6 +537,188 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
}
|
||||
}, [fetchSelectedRoadmap, projectId]);
|
||||
|
||||
// ── Milestone Suggestion Actions (Ephemeral) ───────────────────────────────────
|
||||
|
||||
const generateMilestoneSuggestions = useCallback(async (
|
||||
goalPrompt: string,
|
||||
count: number = 5,
|
||||
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
||||
) => {
|
||||
const currentRoadmapId = selectedRoadmapIdRef.current;
|
||||
if (!currentRoadmapId) {
|
||||
const error = new Error("No roadmap selected");
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Capture project context version for stale-response protection
|
||||
const contextVersionAtStart = projectContextVersionRef.current;
|
||||
const requestProjectId = projectIdRef.current;
|
||||
|
||||
setIsGeneratingSuggestions(true);
|
||||
|
||||
try {
|
||||
const response = await api.generateMilestoneSuggestions(
|
||||
currentRoadmapId,
|
||||
goalPrompt,
|
||||
count,
|
||||
requestProjectId
|
||||
);
|
||||
|
||||
// Check for stale response
|
||||
if (projectContextVersionRef.current !== contextVersionAtStart) {
|
||||
// Project context changed during fetch - discard response
|
||||
return;
|
||||
}
|
||||
|
||||
setMilestoneSuggestions(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 suggestions");
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
} finally {
|
||||
// Only clear loading state if context hasn't changed
|
||||
if (projectContextVersionRef.current === contextVersionAtStart) {
|
||||
setIsGeneratingSuggestions(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const acceptMilestoneSuggestion = useCallback(async (
|
||||
index: number,
|
||||
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
||||
) => {
|
||||
const currentRoadmapId = selectedRoadmapIdRef.current;
|
||||
if (!currentRoadmapId) {
|
||||
const error = new Error("No roadmap selected");
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Capture state for stale-response protection
|
||||
const contextVersionAtStart = projectContextVersionRef.current;
|
||||
const currentSuggestions = milestoneSuggestions;
|
||||
|
||||
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
|
||||
setMilestoneSuggestions((prev) => prev.filter((_, i) => i !== index));
|
||||
|
||||
try {
|
||||
await api.createRoadmapMilestone(
|
||||
currentRoadmapId,
|
||||
{ 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)
|
||||
setMilestoneSuggestions((prev) => {
|
||||
const updated = [...prev];
|
||||
updated.splice(index, 0, suggestion);
|
||||
return updated;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh the roadmap to get the new milestone
|
||||
if (selectedRoadmapIdRef.current) {
|
||||
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
|
||||
}
|
||||
|
||||
opts?.onSuccess?.();
|
||||
} catch (err) {
|
||||
// Rollback: re-add to suggestions
|
||||
setMilestoneSuggestions((prev) => {
|
||||
const updated = [...prev];
|
||||
updated.splice(index, 0, suggestion);
|
||||
return updated;
|
||||
});
|
||||
|
||||
const error = err instanceof Error ? err : new Error("Failed to accept suggestion");
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}, [milestoneSuggestions, fetchSelectedRoadmap]);
|
||||
|
||||
const acceptAllMilestoneSuggestions = useCallback(async (
|
||||
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
||||
) => {
|
||||
const currentRoadmapId = selectedRoadmapIdRef.current;
|
||||
if (!currentRoadmapId) {
|
||||
const error = new Error("No roadmap selected");
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Capture current suggestions (they will be cleared sequentially)
|
||||
const suggestionsToAccept = [...milestoneSuggestions];
|
||||
if (suggestionsToAccept.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear suggestions immediately (optimistic)
|
||||
setMilestoneSuggestions([]);
|
||||
|
||||
// Capture state for stale-response protection
|
||||
const contextVersionAtStart = projectContextVersionRef.current;
|
||||
|
||||
// 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.createRoadmapMilestone(
|
||||
currentRoadmapId,
|
||||
{ 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 milestones
|
||||
if (selectedRoadmapIdRef.current) {
|
||||
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
|
||||
}
|
||||
|
||||
opts?.onSuccess?.();
|
||||
}, [milestoneSuggestions, fetchSelectedRoadmap]);
|
||||
|
||||
const clearMilestoneSuggestions = useCallback(() => {
|
||||
setMilestoneSuggestions([]);
|
||||
setIsGeneratingSuggestions(false);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
await fetchRoadmaps();
|
||||
if (selectedRoadmapIdRef.current) {
|
||||
@@ -535,6 +747,12 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
deleteFeature,
|
||||
reorderFeatures,
|
||||
moveFeature,
|
||||
milestoneSuggestions,
|
||||
isGeneratingSuggestions,
|
||||
generateMilestoneSuggestions,
|
||||
acceptMilestoneSuggestion,
|
||||
acceptAllMilestoneSuggestions,
|
||||
clearMilestoneSuggestions,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30971,6 +30971,208 @@ html .column.drag-over * {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* === Roadmap Suggestion Section === */
|
||||
|
||||
.roadmap-suggestion-section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-lg);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.roadmap-suggestion-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.roadmap-suggestion-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.roadmap-suggestion-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.roadmap-suggestion-input {
|
||||
width: 100%;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-input);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.roadmap-suggestion-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.roadmap-suggestion-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.roadmap-suggestion-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.roadmap-suggestion-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.roadmap-suggestion-generate-btn {
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.roadmap-suggestion-generate-btn:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.roadmap-suggestion-generate-btn:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.roadmap-suggestion-generate-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.roadmap-suggestion-accept-all-btn {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--color-success);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.roadmap-suggestion-accept-all-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.roadmap-suggestion-accept-all-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.roadmap-suggestion-clear-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.roadmap-suggestion-clear-btn:hover {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.roadmap-suggestion-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
.roadmap-suggestion-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-md);
|
||||
background: var(--surface-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.roadmap-suggestion-card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.roadmap-suggestion-card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.roadmap-suggestion-card-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.roadmap-suggestion-card-desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.roadmap-suggestion-card-actions {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.roadmap-suggestion-accept-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: var(--color-success);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.roadmap-suggestion-accept-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.roadmap-suggestion-accept-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.roadmaps-view__sidebar {
|
||||
@@ -30987,5 +31189,27 @@ html .column.drag-over * {
|
||||
width: 100%;
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
/* Roadmap suggestion mobile styles */
|
||||
.roadmap-suggestion-section {
|
||||
padding: var(--space-md);
|
||||
margin: var(--space-md);
|
||||
}
|
||||
|
||||
.roadmap-suggestion-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.roadmap-suggestion-generate-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.roadmap-suggestion-accept-all-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.roadmap-suggestion-card {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
619
packages/dashboard/src/roadmap-suggestions.test.ts
Normal file
619
packages/dashboard/src/roadmap-suggestions.test.ts
Normal file
@@ -0,0 +1,619 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
validateSuggestionInput,
|
||||
generateMilestoneSuggestions,
|
||||
ValidationError,
|
||||
ParseError,
|
||||
__resetSuggestionState,
|
||||
__setCreateKbAgent,
|
||||
} from "./roadmap-suggestions";
|
||||
|
||||
describe("roadmap-suggestions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
__resetSuggestionState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetSuggestionState();
|
||||
});
|
||||
|
||||
describe("validateSuggestionInput", () => {
|
||||
it("accepts valid input with all fields", () => {
|
||||
const input = {
|
||||
goalPrompt: "Build a modern e-commerce platform",
|
||||
count: 5,
|
||||
};
|
||||
|
||||
expect(() => validateSuggestionInput(input)).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts valid input without optional count", () => {
|
||||
const input = {
|
||||
goalPrompt: "Build a modern e-commerce platform",
|
||||
};
|
||||
|
||||
expect(() => validateSuggestionInput(input)).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts count at minimum boundary (1)", () => {
|
||||
const input = {
|
||||
goalPrompt: "Test goal",
|
||||
count: 1,
|
||||
};
|
||||
|
||||
expect(() => validateSuggestionInput(input)).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts count at maximum boundary (10)", () => {
|
||||
const input = {
|
||||
goalPrompt: "Test goal",
|
||||
count: 10,
|
||||
};
|
||||
|
||||
expect(() => validateSuggestionInput(input)).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects null input", () => {
|
||||
expect(() => validateSuggestionInput(null)).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it("rejects non-object input", () => {
|
||||
expect(() => validateSuggestionInput("string")).toThrow(ValidationError);
|
||||
expect(() => validateSuggestionInput(123)).toThrow(ValidationError);
|
||||
expect(() => validateSuggestionInput([])).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it("rejects missing goalPrompt", () => {
|
||||
expect(() => validateSuggestionInput({})).toThrow(ValidationError);
|
||||
expect(() => validateSuggestionInput({ count: 5 })).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it("rejects non-string goalPrompt", () => {
|
||||
expect(() =>
|
||||
validateSuggestionInput({ goalPrompt: 123 })
|
||||
).toThrow(ValidationError);
|
||||
expect(() =>
|
||||
validateSuggestionInput({ goalPrompt: null })
|
||||
).toThrow(ValidationError);
|
||||
expect(() =>
|
||||
validateSuggestionInput({ goalPrompt: [] })
|
||||
).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it("rejects empty goalPrompt", () => {
|
||||
expect(() =>
|
||||
validateSuggestionInput({ goalPrompt: "" })
|
||||
).toThrow(ValidationError);
|
||||
expect(() =>
|
||||
validateSuggestionInput({ goalPrompt: " " })
|
||||
).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it("rejects goalPrompt exceeding max length", () => {
|
||||
const longPrompt = "a".repeat(4001);
|
||||
expect(() =>
|
||||
validateSuggestionInput({ goalPrompt: longPrompt })
|
||||
).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it("accepts goalPrompt at exactly max length", () => {
|
||||
const maxPrompt = "a".repeat(4000);
|
||||
expect(() =>
|
||||
validateSuggestionInput({ goalPrompt: maxPrompt })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-integer count", () => {
|
||||
expect(() =>
|
||||
validateSuggestionInput({ goalPrompt: "Test", count: 3.5 })
|
||||
).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it("rejects count below minimum", () => {
|
||||
expect(() =>
|
||||
validateSuggestionInput({ goalPrompt: "Test", count: 0 })
|
||||
).toThrow(ValidationError);
|
||||
expect(() =>
|
||||
validateSuggestionInput({ goalPrompt: "Test", count: -1 })
|
||||
).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it("rejects count above maximum", () => {
|
||||
expect(() =>
|
||||
validateSuggestionInput({ goalPrompt: "Test", count: 11 })
|
||||
).toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateMilestoneSuggestions", () => {
|
||||
const rootDir = "/test/project";
|
||||
|
||||
it("generates milestone suggestions successfully", async () => {
|
||||
const mockSession = {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: '[\n {"title": "Foundation Setup", "description": "Set up core infrastructure"},\n {"title": "User Authentication", "description": "Implement login and user management"}\n]',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
const suggestions = await generateMilestoneSuggestions(
|
||||
"Build a modern e-commerce platform",
|
||||
5,
|
||||
rootDir
|
||||
);
|
||||
|
||||
expect(suggestions).toHaveLength(2);
|
||||
expect(suggestions[0]).toEqual({
|
||||
title: "Foundation Setup",
|
||||
description: "Set up core infrastructure",
|
||||
});
|
||||
expect(suggestions[1]).toEqual({
|
||||
title: "User Authentication",
|
||||
description: "Implement login and user management",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses default count of 5 when not specified", async () => {
|
||||
const mockSession = {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: '[\n {"title": "Setup", "description": "Initial setup"}\n]',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
await generateMilestoneSuggestions("Test goal", undefined, rootDir);
|
||||
|
||||
expect(mockCreateKbAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: rootDir,
|
||||
systemPrompt: expect.stringContaining("milestone"),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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": "Setup", "description": "Initial setup"}\n]',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
await generateMilestoneSuggestions("Test goal", 3, rootDir);
|
||||
|
||||
expect(mockCreateKbAgent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes count in user message", async () => {
|
||||
const mockSession = {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: '[\n {"title": "Setup", "description": "Initial setup"}\n]',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
await generateMilestoneSuggestions("Build a platform", 5, rootDir);
|
||||
|
||||
expect(mockSession.prompt).toHaveBeenCalledWith(
|
||||
expect.stringContaining("5 milestones")
|
||||
);
|
||||
});
|
||||
|
||||
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": "Setup", "description": "Initial setup"}\n]',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
await generateMilestoneSuggestions("Test", 5, rootDir);
|
||||
|
||||
expect(mockSession.dispose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when AI service is unavailable", async () => {
|
||||
__setCreateKbAgent(undefined);
|
||||
|
||||
await expect(
|
||||
generateMilestoneSuggestions("Test", 5, 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(
|
||||
generateMilestoneSuggestions("Test", 5)
|
||||
).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": "Setup", "description": "Initial setup"}\n]\n```',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
const suggestions = await generateMilestoneSuggestions("Test", 5, rootDir);
|
||||
|
||||
expect(suggestions).toHaveLength(1);
|
||||
expect(suggestions[0].title).toBe("Setup");
|
||||
});
|
||||
|
||||
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": "Phase 1", "description": "First phase"}\n]',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
const suggestions = await generateMilestoneSuggestions("Test", 5, rootDir);
|
||||
|
||||
expect(suggestions).toHaveLength(1);
|
||||
expect(suggestions[0].title).toBe("Phase 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": "Phase 1"}, {"title": "Phase 2", "description": "With desc"}]',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
const suggestions = await generateMilestoneSuggestions("Test", 5, rootDir);
|
||||
|
||||
expect(suggestions).toHaveLength(2);
|
||||
expect(suggestions[0]).toEqual({ title: "Phase 1", description: undefined });
|
||||
expect(suggestions[1]).toEqual({ title: "Phase 2", description: "With desc" });
|
||||
});
|
||||
|
||||
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 generateMilestoneSuggestions("Test", 2, 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 generateMilestoneSuggestions("Test", 5, 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 milestones without JSON",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
await expect(
|
||||
generateMilestoneSuggestions("Test", 5, 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(
|
||||
generateMilestoneSuggestions("Test", 5, rootDir)
|
||||
).rejects.toThrow(ParseError);
|
||||
});
|
||||
|
||||
it("throws ParseError when milestone 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(
|
||||
generateMilestoneSuggestions("Test", 5, 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": "Setup", "description": "Initial setup"}\n]',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
await generateMilestoneSuggestions(
|
||||
"Test",
|
||||
5,
|
||||
rootDir,
|
||||
"openai",
|
||||
"gpt-4o"
|
||||
);
|
||||
|
||||
expect(mockCreateKbAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
defaultProvider: "openai",
|
||||
defaultModelId: "gpt-4o",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
521
packages/dashboard/src/roadmap-suggestions.ts
Normal file
521
packages/dashboard/src/roadmap-suggestions.ts
Normal file
@@ -0,0 +1,521 @@
|
||||
/**
|
||||
* Roadmap Milestone Suggestion Generation Service
|
||||
*
|
||||
* Provides AI-powered milestone suggestion generation for roadmaps.
|
||||
* Users can generate milestone ideas from a goal prompt and accept them
|
||||
* into their roadmap.
|
||||
*
|
||||
* Features:
|
||||
* - AI agent integration via dynamic import of @fusion/engine
|
||||
* - Planning-style JSON extraction with repair
|
||||
* - Input validation (goal prompt max length, count bounds)
|
||||
* - Read-only endpoint (no persistence of suggestions)
|
||||
* - Error mapping (validation 400, not found 404, AI/parser 500/503)
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
// Dynamic import for @fusion/engine to avoid resolution issues in test environment
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let createKbAgent: any;
|
||||
|
||||
// Track if engine has been initialized (prevents multiple imports)
|
||||
let engineInitialized = false;
|
||||
|
||||
// Flag to indicate if createKbAgent was explicitly set (even to undefined)
|
||||
let createKbAgentExplicitlySet = false;
|
||||
|
||||
// Initialize the import (this runs in actual server, mocked in tests)
|
||||
async function initEngine(): Promise<void> {
|
||||
if (engineInitialized) return;
|
||||
|
||||
// If createKbAgent was explicitly set (even to undefined), don't try to import
|
||||
if (createKbAgentExplicitlySet) {
|
||||
engineInitialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!createKbAgent) {
|
||||
try {
|
||||
// Use dynamic import with variable to prevent static analysis
|
||||
const engineModule = "@fusion/engine";
|
||||
const engine = await import(/* @vite-ignore */ engineModule);
|
||||
createKbAgent = engine.createKbAgent;
|
||||
} catch {
|
||||
// Allow failure in test environments - agent functionality will be stubbed
|
||||
createKbAgent = undefined;
|
||||
}
|
||||
}
|
||||
engineInitialized = true;
|
||||
}
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Input for generating milestone suggestions */
|
||||
export interface GenerateMilestoneSuggestionsInput {
|
||||
/** The goal prompt/description for the roadmap */
|
||||
goalPrompt: string;
|
||||
/** Number of milestones to generate (default 5, max 10) */
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/** A suggested milestone with title and optional description */
|
||||
export interface MilestoneSuggestion {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** System prompt for milestone suggestion generation */
|
||||
export const MILESTONE_SUGGESTION_SYSTEM_PROMPT = `You are a milestone planning assistant for a product roadmap system.
|
||||
|
||||
Your job is to suggest logical milestones that would help achieve a user's roadmap goal.
|
||||
|
||||
## Guidelines
|
||||
|
||||
1. **Think about phases**: Break the goal into logical phases (e.g., "Foundation", "Core Features", "Polish", "Launch")
|
||||
2. **Use clear titles**: Milestone titles should be concise and descriptive (e.g., "Authentication System", "User Dashboard MVP")
|
||||
3. **Add context**: Include a brief description explaining what this milestone encompasses
|
||||
4. **Order matters**: List milestones in the order they should be completed
|
||||
5. **Realistic scope**: Each milestone should be achievable in 2-4 weeks
|
||||
|
||||
## Output Format
|
||||
|
||||
Respond with ONLY a valid JSON array of milestone suggestions:
|
||||
|
||||
[
|
||||
{
|
||||
"title": "Milestone Title",
|
||||
"description": "Brief description of what this milestone covers (1-2 sentences)"
|
||||
},
|
||||
...
|
||||
]
|
||||
|
||||
Do NOT include any markdown formatting, code fences, or additional text. Only output the JSON array.`;
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Maximum length for goal prompt */
|
||||
const MAX_GOAL_PROMPT_LENGTH = 4000;
|
||||
|
||||
/** Default number of suggestions to generate */
|
||||
const DEFAULT_SUGGESTION_COUNT = 5;
|
||||
|
||||
/** Maximum number of suggestions to generate */
|
||||
const MAX_SUGGESTION_COUNT = 10;
|
||||
|
||||
/** Minimum number of suggestions to generate */
|
||||
const MIN_SUGGESTION_COUNT = 1;
|
||||
|
||||
/** Max number of retry attempts when AI returns unparseable output */
|
||||
const MAX_PARSE_RETRIES = 1;
|
||||
|
||||
// ── Validation ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate the input for generating milestone suggestions.
|
||||
* Throws with a descriptive error message on validation failure.
|
||||
*/
|
||||
export function validateSuggestionInput(input: unknown): asserts input is GenerateMilestoneSuggestionsInput {
|
||||
if (!input || typeof input !== "object") {
|
||||
throw new ValidationError("Request body must be an object");
|
||||
}
|
||||
|
||||
const { goalPrompt, count } = input as Record<string, unknown>;
|
||||
|
||||
// Validate goalPrompt
|
||||
if (typeof goalPrompt !== "string" || !goalPrompt.trim()) {
|
||||
throw new ValidationError("goalPrompt is required and must be a non-empty string");
|
||||
}
|
||||
|
||||
if (goalPrompt.length > MAX_GOAL_PROMPT_LENGTH) {
|
||||
throw new ValidationError(
|
||||
`goalPrompt exceeds maximum length of ${MAX_GOAL_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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── JSON Extraction ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract the best JSON candidate from AI response text.
|
||||
* Handles markdown-wrapped JSON, embedded JSON, and balanced brace extraction.
|
||||
*/
|
||||
function extractJsonCandidate(text: string): string | null {
|
||||
if (!text || !text.trim()) return null;
|
||||
|
||||
// 1. Try markdown code blocks first (most reliable)
|
||||
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
||||
if (codeBlockMatch?.[1]) {
|
||||
const candidate = codeBlockMatch[1].trim();
|
||||
if (candidate.startsWith("[")) return candidate;
|
||||
}
|
||||
|
||||
// 2. Find all top-level bracket-delimited arrays using balanced counting
|
||||
const candidates: Array<{ start: number; end: number; text: string }> = [];
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] === "[") {
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
for (let j = i; j < text.length; j++) {
|
||||
const ch = text[j];
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
if (inString) continue;
|
||||
if (ch === "[") depth++;
|
||||
if (ch === "]") depth--;
|
||||
if (depth === 0) {
|
||||
const candidate = text.slice(i, j + 1).trim();
|
||||
// Only accept candidates that parse as valid JSON
|
||||
try {
|
||||
JSON.parse(candidate);
|
||||
candidates.push({ start: i, end: j, text: candidate });
|
||||
} catch {
|
||||
// Not valid JSON, skip
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pick the largest valid candidate (most likely the full response)
|
||||
if (candidates.length > 0) {
|
||||
candidates.sort((a, b) => b.text.length - a.text.length);
|
||||
return candidates[0].text;
|
||||
}
|
||||
|
||||
// 3. Last resort: try the full trimmed text
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.startsWith("[")) return trimmed;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to repair common JSON issues:
|
||||
* - Truncated JSON (missing closing brackets/braces)
|
||||
* - Trailing commas before closing brackets/braces
|
||||
* - Missing closing quotes
|
||||
*/
|
||||
function repairJson(text: string): string {
|
||||
let repaired = text;
|
||||
|
||||
// Fix trailing commas before } or ]
|
||||
repaired = repaired.replace(/,\s*([}\]])/g, "$1");
|
||||
|
||||
// Count open/close braces and brackets
|
||||
let openBraces = 0;
|
||||
let openBrackets = 0;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
for (const ch of repaired) {
|
||||
if (escape) { escape = false; continue; }
|
||||
if (ch === "\\") { escape = true; continue; }
|
||||
if (ch === '"') { inString = !inString; continue; }
|
||||
if (inString) continue;
|
||||
if (ch === "{") openBraces++;
|
||||
if (ch === "}") openBraces--;
|
||||
if (ch === "[") openBrackets++;
|
||||
if (ch === "]") openBrackets--;
|
||||
}
|
||||
|
||||
// If we're in an unclosed string, close it
|
||||
if (inString) {
|
||||
repaired += '"';
|
||||
}
|
||||
|
||||
// Re-count after potential string fix
|
||||
openBraces = 0;
|
||||
openBrackets = 0;
|
||||
inString = false;
|
||||
escape = false;
|
||||
for (const ch of repaired) {
|
||||
if (escape) { escape = false; continue; }
|
||||
if (ch === "\\") { escape = true; continue; }
|
||||
if (ch === '"') { inString = !inString; continue; }
|
||||
if (inString) continue;
|
||||
if (ch === "{") openBraces++;
|
||||
if (ch === "}") openBraces--;
|
||||
if (ch === "[") openBrackets++;
|
||||
if (ch === "]") openBrackets--;
|
||||
}
|
||||
|
||||
// Close unclosed brackets and braces
|
||||
repaired += "]".repeat(Math.max(0, openBrackets));
|
||||
repaired += "}".repeat(Math.max(0, openBraces));
|
||||
|
||||
return repaired;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse AI response JSON with robust extraction and recovery.
|
||||
*/
|
||||
function parseMilestoneSuggestions(text: string): MilestoneSuggestion[] {
|
||||
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 milestone suggestions");
|
||||
}
|
||||
|
||||
// Validate and normalize each item
|
||||
const suggestions: MilestoneSuggestion[] = [];
|
||||
for (let i = 0; i < parsed.length; i++) {
|
||||
const item = parsed[i];
|
||||
if (!item || typeof item !== "object") {
|
||||
throw new ParseError(`Item ${i + 1} in AI response is invalid`);
|
||||
}
|
||||
|
||||
const { title, description } = item as Record<string, unknown>;
|
||||
|
||||
if (typeof title !== "string" || !title.trim()) {
|
||||
throw new ParseError(`Item ${i + 1} in AI response is missing a valid title`);
|
||||
}
|
||||
|
||||
suggestions.push({
|
||||
title: title.trim(),
|
||||
description: typeof description === "string" && description.trim()
|
||||
? description.trim()
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
throw new ParseError("AI returned no milestone suggestions");
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
// ── Generation ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate milestone suggestions from a goal prompt.
|
||||
*
|
||||
* @param goalPrompt - The goal/description for the roadmap
|
||||
* @param count - Number of suggestions to generate (default 5, max 10)
|
||||
* @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 milestone suggestions
|
||||
*/
|
||||
export async function generateMilestoneSuggestions(
|
||||
goalPrompt: string,
|
||||
count: number = DEFAULT_SUGGESTION_COUNT,
|
||||
rootDir?: string,
|
||||
modelProvider?: string,
|
||||
modelId?: string,
|
||||
): Promise<MilestoneSuggestion[]> {
|
||||
// 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");
|
||||
}
|
||||
|
||||
// Create a unique session ID for this generation
|
||||
const sessionId = randomUUID();
|
||||
|
||||
let agent: ReturnType<typeof createKbAgent> | undefined;
|
||||
|
||||
try {
|
||||
// Create AI agent with milestone suggestion system prompt
|
||||
agent = await createKbAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
||||
tools: "readonly",
|
||||
...(modelProvider && modelId
|
||||
? {
|
||||
defaultProvider: modelProvider,
|
||||
defaultModelId: modelId,
|
||||
}
|
||||
: {}),
|
||||
onThinking: () => {
|
||||
// Ignore thinking output for milestone suggestions
|
||||
},
|
||||
onText: () => {
|
||||
// Ignore incremental text
|
||||
},
|
||||
});
|
||||
|
||||
// Send the goal prompt with count instruction
|
||||
const userMessage = `Please suggest ${count} milestones for the following roadmap goal:\n\n${goalPrompt.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: MilestoneSuggestion[] | undefined;
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
|
||||
try {
|
||||
suggestions = parseMilestoneSuggestions(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 milestone suggestions in this format: " +
|
||||
'[{"title": "Milestone 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 {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ParseError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ServiceUnavailableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ServiceUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reset module state. Used for testing only.
|
||||
*/
|
||||
export function __resetSuggestionState(): void {
|
||||
createKbAgent = undefined;
|
||||
engineInitialized = false;
|
||||
createKbAgentExplicitlySet = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a mock createKbAgent function. Used for testing only.
|
||||
*/
|
||||
export function __setCreateKbAgent(mock: typeof createKbAgent): void {
|
||||
createKbAgent = mock;
|
||||
createKbAgentExplicitlySet = true;
|
||||
}
|
||||
@@ -42,6 +42,13 @@ import {
|
||||
import { getMissionInterviewSession, cleanupMissionInterviewSession } from "./mission-interview.js";
|
||||
import { getTargetInterviewSession, cleanupTargetInterviewSession } from "./milestone-slice-interview.js";
|
||||
import { writeSSEEvent } from "./sse-buffer.js";
|
||||
import {
|
||||
generateMilestoneSuggestions,
|
||||
validateSuggestionInput,
|
||||
ValidationError as SuggestionValidationError,
|
||||
ParseError as SuggestionParseError,
|
||||
ServiceUnavailableError as SuggestionServiceUnavailableError,
|
||||
} from "./roadmap-suggestions.js";
|
||||
import {
|
||||
ApiError,
|
||||
badRequest,
|
||||
@@ -2715,6 +2722,63 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// ── Roadmap Milestone Suggestions ───────────────────────────────────────
|
||||
|
||||
// Generate milestone suggestions from a goal prompt
|
||||
router.post("/roadmaps/:roadmapId/suggestions/milestones", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const roadmapStore = scopedStore.getRoadmapStore();
|
||||
const { roadmapId } = req.params;
|
||||
|
||||
// Check if roadmap exists
|
||||
const roadmap = roadmapStore.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw notFound(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
|
||||
// Validate input
|
||||
let input: { goalPrompt: string; count?: number };
|
||||
try {
|
||||
validateSuggestionInput(req.body);
|
||||
input = req.body as { goalPrompt: string; count?: number };
|
||||
} catch (err) {
|
||||
if (err instanceof SuggestionValidationError) {
|
||||
throw badRequest(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Get project root directory for AI context
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
|
||||
// Generate suggestions
|
||||
try {
|
||||
const suggestions = await generateMilestoneSuggestions(
|
||||
input.goalPrompt,
|
||||
input.count,
|
||||
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 milestone suggestions");
|
||||
}
|
||||
});
|
||||
|
||||
// List all tasks
|
||||
router.get("/tasks", async (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user