feat(FN-892): add planning mode multi-task creation with Break into Tasks
- Add generateSubtasksFromPlanning function to generate subtasks from planning session key deliverables - Add POST /planning/start-breakdown and POST /planning/create-tasks API routes - Add startPlanningBreakdown and createTasksFromPlanning frontend API functions - Extend PlanningModeModal with Break into Tasks UI and subtask editing (drag-and-drop, dependency validation) - Add comprehensive tests for generateSubtasksFromPlanning with edge cases - Document planning mode multi-task creation feature in AGENTS.md
This commit is contained in:
@@ -381,6 +381,13 @@ function AppInner() {
|
||||
setPlanningInitialPlan(null);
|
||||
}, [addToast]);
|
||||
|
||||
const handlePlanningTasksCreated = useCallback((createdTasks: Task[]) => {
|
||||
const ids = createdTasks.map((task) => task.id).join(", ");
|
||||
addToast(`Created ${ids} from planning mode`, "success");
|
||||
setIsPlanningOpen(false);
|
||||
setPlanningInitialPlan(null);
|
||||
}, [addToast]);
|
||||
|
||||
// Handle planning mode from new task dialog
|
||||
const handleNewTaskPlanningMode = useCallback((initialPlan: string) => {
|
||||
setPlanningInitialPlan(initialPlan);
|
||||
@@ -699,6 +706,7 @@ function AppInner() {
|
||||
isOpen={isPlanningOpen}
|
||||
onClose={handlePlanningClose}
|
||||
onTaskCreated={handlePlanningTaskCreated}
|
||||
onTasksCreated={handlePlanningTasksCreated}
|
||||
tasks={tasks}
|
||||
initialPlan={planningInitialPlan ?? undefined}
|
||||
projectId={currentProject?.id}
|
||||
|
||||
@@ -1165,6 +1165,39 @@ export function createTaskFromPlanning(sessionId: string, projectId?: string): P
|
||||
});
|
||||
}
|
||||
|
||||
/** Start subtask breakdown from a completed planning session */
|
||||
export function startPlanningBreakdown(
|
||||
sessionId: string,
|
||||
projectId?: string,
|
||||
): Promise<{ sessionId: string; subtasks: SubtaskItem[] }> {
|
||||
return api<{ sessionId: string; subtasks: SubtaskItem[] }>(
|
||||
withProjectId("/planning/start-breakdown", projectId),
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionId }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Create multiple tasks from a completed planning session */
|
||||
export function createTasksFromPlanning(
|
||||
planningSessionId: string,
|
||||
subtasks: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
suggestedSize: "S" | "M" | "L";
|
||||
dependsOn: string[];
|
||||
}>,
|
||||
projectId?: string,
|
||||
): Promise<{ tasks: Task[] }> {
|
||||
return api<{ tasks: Task[] }>(withProjectId("/planning/create-tasks", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ planningSessionId, subtasks }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/** Get the SSE stream URL for a planning session */
|
||||
export function getPlanningStreamUrl(sessionId: string, projectId?: string): string {
|
||||
return buildApiUrl(withProjectId(`/planning/${encodeURIComponent(sessionId)}/stream`, projectId));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||
import type { Task, PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||
import {
|
||||
startPlanning,
|
||||
@@ -8,14 +8,18 @@ import {
|
||||
createTaskFromPlanning,
|
||||
connectPlanningStream,
|
||||
fetchAiSession,
|
||||
startPlanningBreakdown,
|
||||
createTasksFromPlanning,
|
||||
type PlanningSession,
|
||||
type SubtaskItem,
|
||||
} from "../api";
|
||||
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles } from "lucide-react";
|
||||
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
interface PlanningModeModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onTaskCreated: (task: Task) => void;
|
||||
onTasksCreated: (tasks: Task[]) => void;
|
||||
tasks: Task[];
|
||||
initialPlan?: string;
|
||||
projectId?: string;
|
||||
@@ -31,7 +35,9 @@ type ViewState =
|
||||
| { type: "initial" }
|
||||
| { type: "question"; session: PlanningSession }
|
||||
| { type: "summary"; session: PlanningSession; summary: PlanningSummary }
|
||||
| { type: "loading" };
|
||||
| { type: "breakdown"; sessionId: string; subtasks: SubtaskItem[]; dirty: boolean }
|
||||
| { type: "loading" }
|
||||
| { type: "creating" };
|
||||
|
||||
const EXAMPLE_PLANS = [
|
||||
"Build a user authentication system with login and signup",
|
||||
@@ -40,7 +46,7 @@ const EXAMPLE_PLANS = [
|
||||
"Refactor the task card component for better performance",
|
||||
];
|
||||
|
||||
export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initialPlan: initialPlanProp, projectId, resumeSessionId }: PlanningModeModalProps) {
|
||||
export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreated, tasks, initialPlan: initialPlanProp, projectId, resumeSessionId }: PlanningModeModalProps) {
|
||||
const [initialPlan, setInitialPlan] = useState("");
|
||||
const [view, setView] = useState<ViewState>({ type: "initial" });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -317,6 +323,51 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
||||
}
|
||||
}, [view, onTaskCreated, handleCancel]);
|
||||
|
||||
const handleStartBreakdown = useCallback(async () => {
|
||||
if (view.type !== "summary") return;
|
||||
|
||||
setError(null);
|
||||
setView({ type: "loading" });
|
||||
|
||||
try {
|
||||
const result = await startPlanningBreakdown(view.session.sessionId, projectId);
|
||||
setView({
|
||||
type: "breakdown",
|
||||
sessionId: result.sessionId,
|
||||
subtasks: result.subtasks,
|
||||
dirty: false,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to start breakdown");
|
||||
setView({ type: "summary", session: view.session, summary: view.summary });
|
||||
}
|
||||
}, [view, projectId]);
|
||||
|
||||
const handleCreateTasksFromBreakdown = useCallback(async () => {
|
||||
if (view.type !== "breakdown") return;
|
||||
|
||||
setError(null);
|
||||
setView({ type: "creating" });
|
||||
|
||||
try {
|
||||
const result = await createTasksFromPlanning(view.sessionId, view.subtasks, projectId);
|
||||
onTasksCreated(result.tasks);
|
||||
// Reset and close
|
||||
setInitialPlan("");
|
||||
setView({ type: "initial" });
|
||||
setError(null);
|
||||
setResponseHistory([]);
|
||||
setEditedSummary(null);
|
||||
setStreamingOutput("");
|
||||
setHasProgress(false);
|
||||
currentSessionIdRef.current = null;
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to create tasks");
|
||||
setView({ type: "breakdown", sessionId: view.sessionId, subtasks: view.subtasks, dirty: view.dirty });
|
||||
}
|
||||
}, [view, onTasksCreated, onClose, projectId]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (view.type === "question" && responseHistory.length > 0) {
|
||||
// Remove last response and go back
|
||||
@@ -434,6 +485,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === "creating" && (
|
||||
<div className="planning-loading">
|
||||
<Loader2 size={40} className="spin" style={{ color: "var(--todo)" }} />
|
||||
<p>Creating tasks...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === "question" && view.session.currentQuestion && (
|
||||
<div className="planning-question">
|
||||
<QuestionForm
|
||||
@@ -451,6 +509,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
||||
onSummaryChange={setEditedSummary}
|
||||
tasks={tasks}
|
||||
onCreateTask={handleCreateTask}
|
||||
onBreakIntoTasks={handleStartBreakdown}
|
||||
onRefine={() => {
|
||||
// Reset to question mode for more refinement
|
||||
setView({ type: "question", session: view.session });
|
||||
@@ -458,6 +517,30 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
||||
isLoading={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view.type === "breakdown" && (
|
||||
<BreakdownView
|
||||
subtasks={view.subtasks}
|
||||
dirty={view.dirty}
|
||||
isLoading={false}
|
||||
onUpdateSubtasks={(newSubtasks) =>
|
||||
setView({ ...view, subtasks: newSubtasks, dirty: true })
|
||||
}
|
||||
onCreateTasks={handleCreateTasksFromBreakdown}
|
||||
onBack={() => {
|
||||
// Return to summary view — re-fetch the session
|
||||
const sessionId = view.sessionId;
|
||||
const session: PlanningSession = {
|
||||
sessionId,
|
||||
currentQuestion: null,
|
||||
summary: editedSummary ?? null,
|
||||
};
|
||||
if (editedSummary) {
|
||||
setView({ type: "summary", session, summary: editedSummary });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -644,6 +727,7 @@ interface SummaryViewProps {
|
||||
onSummaryChange: (summary: PlanningSummary) => void;
|
||||
tasks: Task[];
|
||||
onCreateTask: () => void;
|
||||
onBreakIntoTasks: () => void;
|
||||
onRefine: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
@@ -653,6 +737,7 @@ function SummaryView({
|
||||
onSummaryChange,
|
||||
tasks,
|
||||
onCreateTask,
|
||||
onBreakIntoTasks,
|
||||
onRefine,
|
||||
isLoading,
|
||||
}: SummaryViewProps) {
|
||||
@@ -761,21 +846,400 @@ function SummaryView({
|
||||
<ArrowLeft size={16} style={{ marginRight: "4px" }} />
|
||||
Refine Further
|
||||
</button>
|
||||
<div className="planning-summary-actions-right">
|
||||
<button
|
||||
className="btn"
|
||||
onClick={onBreakIntoTasks}
|
||||
disabled={isLoading}
|
||||
title="Break the plan into multiple tasks with dependencies"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 size={16} className="spin" style={{ marginRight: "8px" }} />
|
||||
Breaking down...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ListTree size={16} style={{ marginRight: "8px" }} />
|
||||
Break into Tasks
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={onCreateTask}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 size={16} className="spin" style={{ marginRight: "8px" }} />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle size={16} style={{ marginRight: "8px" }} />
|
||||
Create Task
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── BreakdownView (subtask editing in planning modal) ──────────────────────
|
||||
|
||||
function hasDependencyCycle(subtasks: SubtaskItem[]): boolean {
|
||||
const graph = new Map(subtasks.map((item) => [item.id, item.dependsOn]));
|
||||
const visiting = new Set<string>();
|
||||
const visited = new Set<string>();
|
||||
|
||||
const visit = (id: string): boolean => {
|
||||
if (visiting.has(id)) return true;
|
||||
if (visited.has(id)) return false;
|
||||
visiting.add(id);
|
||||
for (const dep of graph.get(id) ?? []) {
|
||||
if (graph.has(dep) && visit(dep)) return true;
|
||||
}
|
||||
visiting.delete(id);
|
||||
visited.add(id);
|
||||
return false;
|
||||
};
|
||||
|
||||
return subtasks.some((item) => visit(item.id));
|
||||
}
|
||||
|
||||
function createEmptySubtask(index: number): SubtaskItem {
|
||||
return {
|
||||
id: `subtask-${index}`,
|
||||
title: "",
|
||||
description: "",
|
||||
suggestedSize: "M",
|
||||
dependsOn: [],
|
||||
};
|
||||
}
|
||||
|
||||
interface BreakdownViewProps {
|
||||
subtasks: SubtaskItem[];
|
||||
dirty: boolean;
|
||||
isLoading: boolean;
|
||||
onUpdateSubtasks: (subtasks: SubtaskItem[]) => void;
|
||||
onCreateTasks: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
function BreakdownView({
|
||||
subtasks,
|
||||
dirty: _dirty,
|
||||
isLoading,
|
||||
onUpdateSubtasks,
|
||||
onCreateTasks,
|
||||
onBack,
|
||||
}: BreakdownViewProps) {
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
||||
const [dragOverPosition, setDragOverPosition] = useState<"before" | "after" | null>(null);
|
||||
const titleRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
|
||||
const isInvalid = useMemo(() => {
|
||||
if (subtasks.length === 0) return true;
|
||||
if (subtasks.some((s) => !s.title.trim())) return true;
|
||||
return hasDependencyCycle(subtasks);
|
||||
}, [subtasks]);
|
||||
|
||||
const updateSubtask = useCallback(
|
||||
(id: string, patch: Partial<SubtaskItem>) => {
|
||||
onUpdateSubtasks(subtasks.map((item) => (item.id === id ? { ...item, ...patch } : item)));
|
||||
},
|
||||
[subtasks, onUpdateSubtasks],
|
||||
);
|
||||
|
||||
const addSubtask = useCallback(() => {
|
||||
onUpdateSubtasks([...subtasks, createEmptySubtask(subtasks.length + 1)]);
|
||||
}, [subtasks, onUpdateSubtasks]);
|
||||
|
||||
const removeSubtask = useCallback(
|
||||
(id: string) => {
|
||||
onUpdateSubtasks(
|
||||
subtasks
|
||||
.filter((item) => item.id !== id)
|
||||
.map((item) => ({ ...item, dependsOn: item.dependsOn.filter((dep) => dep !== id) })),
|
||||
);
|
||||
},
|
||||
[subtasks, onUpdateSubtasks],
|
||||
);
|
||||
|
||||
const moveSubtask = useCallback(
|
||||
(fromIndex: number, toIndex: number) => {
|
||||
if (toIndex < 0 || toIndex >= subtasks.length) return;
|
||||
const newSubtasks = [...subtasks];
|
||||
const [moved] = newSubtasks.splice(fromIndex, 1);
|
||||
newSubtasks.splice(toIndex, 0, moved);
|
||||
onUpdateSubtasks(newSubtasks);
|
||||
},
|
||||
[subtasks, onUpdateSubtasks],
|
||||
);
|
||||
|
||||
// Drag-and-drop handlers
|
||||
const handleDragStart = useCallback((subtaskId: string) => (e: React.DragEvent) => {
|
||||
setDraggingId(subtaskId);
|
||||
e.dataTransfer.setData("text/plain", subtaskId);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDraggingId(null);
|
||||
setDragOverId(null);
|
||||
setDragOverPosition(null);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((targetId: string) => (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (targetId === draggingId) return;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const midY = rect.top + rect.height / 2;
|
||||
const position: "before" | "after" = e.clientY < midY ? "before" : "after";
|
||||
setDragOverId(targetId);
|
||||
setDragOverPosition(position);
|
||||
}, [draggingId]);
|
||||
|
||||
const handleDrop = useCallback((targetId: string) => (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const draggedId = e.dataTransfer.getData("text/plain");
|
||||
if (!draggedId || draggedId === targetId) {
|
||||
handleDragEnd();
|
||||
return;
|
||||
}
|
||||
const fromIndex = subtasks.findIndex((s) => s.id === draggedId);
|
||||
const toIndex = subtasks.findIndex((s) => s.id === targetId);
|
||||
if (fromIndex === -1 || toIndex === -1) {
|
||||
handleDragEnd();
|
||||
return;
|
||||
}
|
||||
const newSubtasks = [...subtasks];
|
||||
const [moved] = newSubtasks.splice(fromIndex, 1);
|
||||
let insertIndex = toIndex;
|
||||
if (dragOverPosition === "after" && fromIndex < toIndex) insertIndex--;
|
||||
if (dragOverPosition === "after") insertIndex++;
|
||||
newSubtasks.splice(insertIndex, 0, moved);
|
||||
onUpdateSubtasks(newSubtasks);
|
||||
handleDragEnd();
|
||||
}, [subtasks, dragOverPosition, onUpdateSubtasks, handleDragEnd]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
||||
setDragOverId(null);
|
||||
setDragOverPosition(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="planning-summary">
|
||||
<div className="planning-view-scroll planning-summary-scroll">
|
||||
<div className="planning-summary-header">
|
||||
<ListTree size={24} style={{ color: "var(--triage)" }} />
|
||||
<h4>Break into Tasks</h4>
|
||||
<p className="text-muted">
|
||||
Review and edit the subtasks generated from your plan. Adjust titles,
|
||||
descriptions, sizes, and dependencies before creating.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="planning-summary-form">
|
||||
{subtasks.map((subtask, index) => {
|
||||
const isDragging = draggingId === subtask.id;
|
||||
const isDragOver = dragOverId === subtask.id;
|
||||
const dragClasses = [
|
||||
"task-detail-section",
|
||||
"subtask-item",
|
||||
isDragging ? "subtask-item-dragging" : "",
|
||||
isDragOver ? "subtask-item-drop-target" : "",
|
||||
isDragOver && dragOverPosition === "before" ? "subtask-item-drop-before" : "",
|
||||
isDragOver && dragOverPosition === "after" ? "subtask-item-drop-after" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div
|
||||
key={subtask.id}
|
||||
className={dragClasses}
|
||||
data-testid={`subtask-item-${index}`}
|
||||
draggable={!isLoading}
|
||||
onDragStart={handleDragStart(subtask.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver(subtask.id)}
|
||||
onDrop={handleDrop(subtask.id)}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
<div
|
||||
className="detail-title-row subtask-item-header"
|
||||
style={{ justifyContent: "space-between" }}
|
||||
>
|
||||
<div className="subtask-drag-handle" title="Drag to reorder">
|
||||
<GripVertical size={16} />
|
||||
<strong>{subtask.id}</strong>
|
||||
</div>
|
||||
<div className="subtask-item-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm"
|
||||
onClick={() => moveSubtask(index, index - 1)}
|
||||
disabled={isLoading || index === 0}
|
||||
title="Move up"
|
||||
aria-label="Move subtask up"
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm"
|
||||
onClick={() => moveSubtask(index, index + 1)}
|
||||
disabled={isLoading || index === subtasks.length - 1}
|
||||
title="Move down"
|
||||
aria-label="Move subtask down"
|
||||
>
|
||||
<ArrowDown size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => removeSubtask(subtask.id)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Trash2 size={14} /> Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Title</label>
|
||||
<input
|
||||
ref={(element) => {
|
||||
titleRefs.current[index] = element;
|
||||
}}
|
||||
value={subtask.title}
|
||||
onChange={(event) => updateSubtask(subtask.id, { title: event.target.value })}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (index < subtasks.length - 1) {
|
||||
titleRefs.current[index + 1]?.focus();
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={subtask.description}
|
||||
onChange={(event) =>
|
||||
updateSubtask(subtask.id, { description: event.target.value })
|
||||
}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Size</label>
|
||||
<div className="planning-size-selector">
|
||||
{(["S", "M", "L"] as const).map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
type="button"
|
||||
className={`planning-size-btn ${subtask.suggestedSize === size ? "selected" : ""}`}
|
||||
onClick={() => updateSubtask(subtask.id, { suggestedSize: size })}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Dependencies</label>
|
||||
<div className="planning-deps-list">
|
||||
{subtasks
|
||||
.slice(0, index)
|
||||
.filter((item) => item.id !== subtask.id)
|
||||
.map((candidate) => {
|
||||
const selected = subtask.dependsOn.includes(candidate.id);
|
||||
return (
|
||||
<label
|
||||
key={candidate.id}
|
||||
className={`planning-dep-chip ${selected ? "selected" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={() => {
|
||||
const nextDeps = selected
|
||||
? subtask.dependsOn.filter((dep) => dep !== candidate.id)
|
||||
: [...subtask.dependsOn, candidate.id];
|
||||
updateSubtask(subtask.id, { dependsOn: nextDeps });
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<span className="planning-dep-id">{candidate.id}</span>
|
||||
<span className="planning-dep-title">
|
||||
{candidate.title || "Untitled"}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{index === 0 && (
|
||||
<div className="text-muted">First subtask cannot have dependencies.</div>
|
||||
)}
|
||||
{index > 0 &&
|
||||
subtasks
|
||||
.slice(0, index)
|
||||
.filter((item) => item.id !== subtask.id).length === 0 && (
|
||||
<div className="text-muted">No previous subtasks available.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<button type="button" className="btn" onClick={addSubtask} disabled={isLoading}>
|
||||
<Plus size={16} style={{ marginRight: 6 }} /> Add subtask
|
||||
</button>
|
||||
|
||||
{hasDependencyCycle(subtasks) && (
|
||||
<div className="form-error planning-error">
|
||||
Dependencies contain a cycle. Remove circular references before creating tasks.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="planning-actions planning-summary-actions">
|
||||
<button className="btn" onClick={onBack} disabled={isLoading}>
|
||||
<ArrowLeft size={16} style={{ marginRight: "4px" }} />
|
||||
Back to Summary
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={onCreateTask}
|
||||
disabled={isLoading}
|
||||
onClick={onCreateTasks}
|
||||
disabled={isLoading || isInvalid}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 size={16} className="spin" style={{ marginRight: "8px" }} />
|
||||
<Loader2 size={16} className="spin" style={{ marginRight: 8 }} />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle size={16} style={{ marginRight: "8px" }} />
|
||||
Create Task
|
||||
</>
|
||||
<>Create Tasks</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -10522,6 +10522,12 @@ html .column.drag-over * {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.planning-summary-actions-right {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.planning-loading {
|
||||
display: flex;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
SessionNotFoundError,
|
||||
InvalidSessionStateError,
|
||||
parseAgentResponse,
|
||||
generateSubtasksFromPlanning,
|
||||
} from "./planning.js";
|
||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||
|
||||
@@ -372,4 +373,157 @@ describe("planning module", () => {
|
||||
expect(result.type).toBe("complete");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateSubtasksFromPlanning", () => {
|
||||
/** Helper: create a session and complete it to get a summary */
|
||||
async function createCompletedSession(
|
||||
ip: string,
|
||||
plan: string,
|
||||
overrides?: Partial<PlanningSummary>
|
||||
): Promise<string> {
|
||||
const { sessionId } = await createSession(ip, plan);
|
||||
// Complete the session by submitting 3 responses
|
||||
await submitResponse(sessionId, { scope: "medium" });
|
||||
await submitResponse(sessionId, { requirements: "Test requirements" });
|
||||
await submitResponse(sessionId, { confirm: true });
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
it("returns empty array if session not found", () => {
|
||||
const result = generateSubtasksFromPlanning("non-existent-session-id");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array if session has no summary (not complete)", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, "Incomplete session");
|
||||
|
||||
const result = generateSubtasksFromPlanning(sessionId);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("generates subtasks from keyDeliverables", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const sessionId = await createCompletedSession(mockIp, "Build auth system");
|
||||
|
||||
const result = generateSubtasksFromPlanning(sessionId);
|
||||
|
||||
// The stubbed session generates 3 key deliverables:
|
||||
// "Implementation", "Tests", "Documentation"
|
||||
expect(result.length).toBe(3);
|
||||
|
||||
// First subtask has no dependencies
|
||||
expect(result[0]).toEqual({
|
||||
id: "subtask-1",
|
||||
title: "Implementation",
|
||||
description: expect.any(String),
|
||||
suggestedSize: "S",
|
||||
dependsOn: [],
|
||||
});
|
||||
|
||||
// Second subtask depends on first
|
||||
expect(result[1]).toEqual({
|
||||
id: "subtask-2",
|
||||
title: "Tests",
|
||||
description: expect.any(String),
|
||||
suggestedSize: "M",
|
||||
dependsOn: ["subtask-1"],
|
||||
});
|
||||
|
||||
// Third subtask depends on second
|
||||
expect(result[2]).toEqual({
|
||||
id: "subtask-3",
|
||||
title: "Documentation",
|
||||
description: expect.any(String),
|
||||
suggestedSize: "S",
|
||||
dependsOn: ["subtask-2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("generates fallback subtasks when keyDeliverables is empty", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, "Fallback test");
|
||||
|
||||
// Complete the session normally, then manually clear keyDeliverables
|
||||
await submitResponse(sessionId, { scope: "small" });
|
||||
await submitResponse(sessionId, { requirements: "test" });
|
||||
await submitResponse(sessionId, { confirm: true });
|
||||
|
||||
// Get the session and manually clear keyDeliverables to test fallback
|
||||
const session = getSession(sessionId);
|
||||
expect(session).toBeDefined();
|
||||
if (session?.summary) {
|
||||
session.summary.keyDeliverables = [];
|
||||
}
|
||||
|
||||
const result = generateSubtasksFromPlanning(sessionId);
|
||||
|
||||
expect(result.length).toBe(3);
|
||||
expect(result[0]).toEqual({
|
||||
id: "subtask-1",
|
||||
title: "Define implementation approach",
|
||||
description: expect.any(String),
|
||||
suggestedSize: "S",
|
||||
dependsOn: [],
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
id: "subtask-2",
|
||||
title: "Implement core changes",
|
||||
description: expect.any(String),
|
||||
suggestedSize: "M",
|
||||
dependsOn: ["subtask-1"],
|
||||
});
|
||||
expect(result[2]).toEqual({
|
||||
id: "subtask-3",
|
||||
title: "Verify and polish",
|
||||
description: expect.any(String),
|
||||
suggestedSize: "S",
|
||||
dependsOn: ["subtask-2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("assigns correct sizes based on deliverable position", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, "Multi-deliverable test");
|
||||
|
||||
// Complete the session
|
||||
await submitResponse(sessionId, { scope: "large" });
|
||||
await submitResponse(sessionId, { requirements: "many things" });
|
||||
await submitResponse(sessionId, { confirm: true });
|
||||
|
||||
// Modify to have 5 deliverables for size variety
|
||||
const session = getSession(sessionId);
|
||||
if (session?.summary) {
|
||||
session.summary.keyDeliverables = [
|
||||
"Setup project structure",
|
||||
"Build feature A",
|
||||
"Build feature B",
|
||||
"Build feature C",
|
||||
"Integration tests",
|
||||
];
|
||||
}
|
||||
|
||||
const result = generateSubtasksFromPlanning(sessionId);
|
||||
expect(result.length).toBe(5);
|
||||
|
||||
// First: S, Middle: M, Last: S
|
||||
expect(result[0]?.suggestedSize).toBe("S");
|
||||
expect(result[1]?.suggestedSize).toBe("M");
|
||||
expect(result[2]?.suggestedSize).toBe("M");
|
||||
expect(result[3]?.suggestedSize).toBe("M");
|
||||
expect(result[4]?.suggestedSize).toBe("S");
|
||||
});
|
||||
|
||||
it("uses sequential dependencies between subtasks", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const sessionId = await createCompletedSession(mockIp, "Dependency test");
|
||||
|
||||
const result = generateSubtasksFromPlanning(sessionId);
|
||||
|
||||
// Each subtask depends on the previous one
|
||||
for (let i = 1; i < result.length; i++) {
|
||||
expect(result[i]?.dependsOn).toEqual([`subtask-${i}`]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
PlanningResponse,
|
||||
TaskStore,
|
||||
} from "@fusion/core";
|
||||
import type { SubtaskItem } from "./subtask-breakdown.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { AiSessionStore, AiSessionRow } from "./ai-session-store.js";
|
||||
@@ -1164,6 +1165,61 @@ export function getSummary(sessionId: string): PlanningSummary | undefined {
|
||||
return sessions.get(sessionId)?.summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate subtasks from a completed planning summary.
|
||||
* Uses the planning session's summary to create a SubtaskItem[] for multi-task creation.
|
||||
*
|
||||
* @param sessionId - The planning session ID
|
||||
* @returns Array of SubtaskItem with titles derived from keyDeliverables, or fallback
|
||||
*/
|
||||
export function generateSubtasksFromPlanning(sessionId: string): SubtaskItem[] {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return [];
|
||||
if (!session.summary) return [];
|
||||
|
||||
const { summary } = session;
|
||||
|
||||
// If key deliverables exist, create one subtask per deliverable
|
||||
if (summary.keyDeliverables.length > 0) {
|
||||
return summary.keyDeliverables.map((deliverable, index) => {
|
||||
const id = `subtask-${index + 1}`;
|
||||
const dependsOn = index > 0 ? [`subtask-${index}`] : [] as string[];
|
||||
return {
|
||||
id,
|
||||
title: deliverable,
|
||||
description: summary.description,
|
||||
suggestedSize: index === 0 ? "S" as const : index === summary.keyDeliverables.length - 1 ? "S" as const : "M" as const,
|
||||
dependsOn,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: 3 subtasks
|
||||
return [
|
||||
{
|
||||
id: "subtask-1",
|
||||
title: "Define implementation approach",
|
||||
description: summary.description,
|
||||
suggestedSize: "S" as const,
|
||||
dependsOn: [],
|
||||
},
|
||||
{
|
||||
id: "subtask-2",
|
||||
title: "Implement core changes",
|
||||
description: summary.description,
|
||||
suggestedSize: "M" as const,
|
||||
dependsOn: ["subtask-1"],
|
||||
},
|
||||
{
|
||||
id: "subtask-3",
|
||||
title: "Verify and polish",
|
||||
description: summary.description,
|
||||
suggestedSize: "S" as const,
|
||||
dependsOn: ["subtask-2"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup a session (used after task creation).
|
||||
*/
|
||||
|
||||
@@ -1257,6 +1257,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
"POST /planning/respond",
|
||||
"POST /planning/cancel",
|
||||
"POST /planning/create-task",
|
||||
"POST /planning/start-breakdown",
|
||||
"POST /planning/create-tasks",
|
||||
"GET /planning/:sessionId/stream",
|
||||
];
|
||||
console.debug("[planning:routes:registered]", planningRoutes);
|
||||
@@ -5275,6 +5277,147 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/planning/start-breakdown
|
||||
* Start subtask breakdown from a completed planning session.
|
||||
* Body: { sessionId: string }
|
||||
* Returns: { sessionId: string } — ID of the generated subtask breakdown
|
||||
*/
|
||||
router.post("/planning/start-breakdown", async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.body;
|
||||
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
res.status(400).json({ error: "sessionId is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { getSession, generateSubtasksFromPlanning } = await import("./planning.js");
|
||||
|
||||
const session = getSession(sessionId);
|
||||
if (!session) {
|
||||
res.status(404).json({ error: `Planning session ${sessionId} not found or expired` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session.summary) {
|
||||
res.status(400).json({ error: "Planning session is not complete" });
|
||||
return;
|
||||
}
|
||||
|
||||
const subtasks = generateSubtasksFromPlanning(sessionId);
|
||||
if (subtasks.length === 0) {
|
||||
res.status(400).json({ error: "Could not generate subtasks from planning session" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Return a synthetic session ID (based on the planning session) and the generated subtasks
|
||||
// We use the planning session ID directly as the breakdown session ID
|
||||
res.json({ sessionId, subtasks });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || "Failed to start planning breakdown" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/planning/create-tasks
|
||||
* Create multiple tasks from a completed planning session (after optional editing).
|
||||
* Body: { planningSessionId: string, subtasks: Array<{id, title, description, suggestedSize, dependsOn}> }
|
||||
* Returns: { tasks: Task[] }
|
||||
*/
|
||||
router.post("/planning/create-tasks", async (req, res) => {
|
||||
try {
|
||||
const { planningSessionId, subtasks } = req.body as {
|
||||
planningSessionId?: string;
|
||||
subtasks?: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
suggestedSize: "S" | "M" | "L";
|
||||
dependsOn: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
if (!planningSessionId || typeof planningSessionId !== "string") {
|
||||
res.status(400).json({ error: "planningSessionId is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(subtasks) || subtasks.length === 0) {
|
||||
res.status(400).json({ error: "subtasks must be a non-empty array" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { getSession, cleanupSession } = await import("./planning.js");
|
||||
|
||||
const session = getSession(planningSessionId);
|
||||
if (!session) {
|
||||
res.status(404).json({ error: `Planning session ${planningSessionId} not found or expired` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session.summary) {
|
||||
res.status(400).json({ error: "Planning session is not complete" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate each subtask
|
||||
for (const item of subtasks) {
|
||||
if (!item || typeof item.id !== "string" || typeof item.title !== "string" || !item.title.trim()) {
|
||||
res.status(400).json({ error: "Each subtask must include id and title" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const createdTasks = [] as Awaited<ReturnType<typeof store.createTask>>[];
|
||||
const tempIdToTaskId = new Map<string, string>();
|
||||
|
||||
// Create tasks
|
||||
for (const item of subtasks) {
|
||||
const task = await store.createTask({
|
||||
title: item.title.trim(),
|
||||
description: typeof item.description === "string" ? item.description.trim() : item.title.trim(),
|
||||
column: "triage",
|
||||
dependencies: undefined,
|
||||
});
|
||||
|
||||
tempIdToTaskId.set(item.id, task.id);
|
||||
createdTasks.push(task);
|
||||
|
||||
if (item.suggestedSize === "S" || item.suggestedSize === "M" || item.suggestedSize === "L") {
|
||||
await store.updateTask(task.id, { size: item.suggestedSize });
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve dependencies
|
||||
for (let index = 0; index < subtasks.length; index++) {
|
||||
const item = subtasks[index]!;
|
||||
const created = createdTasks[index]!;
|
||||
const resolvedDependencies = Array.isArray(item.dependsOn)
|
||||
? item.dependsOn.map((dep) => tempIdToTaskId.get(dep)).filter((dep): dep is string => Boolean(dep))
|
||||
: [];
|
||||
|
||||
if (resolvedDependencies.length > 0) {
|
||||
const updated = await store.updateTask(created.id, { dependencies: resolvedDependencies });
|
||||
createdTasks[index] = updated;
|
||||
}
|
||||
|
||||
await store.logEntry(
|
||||
created.id,
|
||||
"Created via Planning Mode (multi-task)",
|
||||
`Source: ${session.initialPlan.slice(0, 200)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup the planning session
|
||||
cleanupSession(planningSessionId);
|
||||
|
||||
res.status(201).json({ tasks: createdTasks });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || "Failed to create tasks from planning" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/planning/:sessionId/stream
|
||||
* SSE endpoint for real-time planning session updates.
|
||||
|
||||
Reference in New Issue
Block a user